@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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import { $t as
|
|
3
|
-
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-
|
|
2
|
+
import { $t as errMsg, An as literal, Dn as boolean, En as array, Fn as string, H as addonWidgetsSourceCapability, In as union, Jt as streamBrokerCapability, K as batteryCapability, Ln as EventCategory, M as RATE_CONTROL_TIGHT, Mn as object, On as discriminatedUnion, Pn as record, Pt as rankStreams, Q as declareLogChannel, Qt as webrtcSessionCapability, R as RingBuffer, Sn as parseProfileBrokerId, Tn as _enum, Vt as resolveEgressDecodeHwAccel, X as createHwAccelCache, Y as clipExportFailureOf, Z as createLogChannelsProvider, _n as isEvent, _t as loadContributionCapability, a as CLIP_BROKER_PACED_RATES, an as BOOT_RECOVERY_BACKOFF_MS, at as egressTransportFromRequest, bn as nodePin, bt as maskUrlCredentials, cn as CamProfileSchema, en as AUDIO_PRESETS, et as deriveBatteryPresence, fn as createEvent, hn as expandAudioChunkToF32le, i as BatteryStatusSchema, in as isSoftwareDecode, it as egressTranscodeSharingKey, j as RATE_CONTROL_RELAXED, jn as number, ln as DeviceFeature, nn as buildFfmpegArgs$1, o as CLIP_STILL_REASON_CODE_HEADER, on as BaseAddon, q as cameraStreamsCapability, rn as invocationFromEncodeProfile, sn as CAM_PROFILE_ORDER, un as DeviceType, vn as makeProfileBrokerId, vt as logChannelsCapability, w as G711_SCALE_CORRECTION_DB, x as EncodeProfileSchema, yn as makeSourceBrokerId } from "../dist-Bn6BQW8q.mjs";
|
|
3
|
+
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-BN0Na3HN.mjs";
|
|
4
4
|
import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
|
|
5
5
|
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
6
6
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
7
|
-
import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-
|
|
7
|
+
import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-CNPzlcyO.mjs";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import { networkInterfaces, tmpdir } from "node:os";
|
|
@@ -23177,6 +23177,246 @@ var H264Repacketizer = class {
|
|
|
23177
23177
|
}
|
|
23178
23178
|
};
|
|
23179
23179
|
//#endregion
|
|
23180
|
+
//#region src/stream-broker/webrtc/delivery-meter.ts
|
|
23181
|
+
/** The window the ladder judges delivery over: at least one GOP. */
|
|
23182
|
+
function deliveryWindowMs(idrIntervalMs, config) {
|
|
23183
|
+
const gop = idrIntervalMs !== null && Number.isFinite(idrIntervalMs) && idrIntervalMs > 0 ? idrIntervalMs : 0;
|
|
23184
|
+
return Math.min(config.maxWindowMs, Math.max(config.minWindowMs, gop));
|
|
23185
|
+
}
|
|
23186
|
+
/**
|
|
23187
|
+
* When a session's meter may start counting client reports. A session created
|
|
23188
|
+
* by a tier change starts after the change blackout (review I1): until the
|
|
23189
|
+
* client swaps its player onto the new PC (after the new stream's first
|
|
23190
|
+
* decode, 2–5 s), its reporter still reads the OLD PC — 0 kbps — and the
|
|
23191
|
+
* first report after the swap is 0 too; both fall inside the new session's
|
|
23192
|
+
* sends and read as a failed rung.
|
|
23193
|
+
*/
|
|
23194
|
+
function deliveryMeterStart(now, afterTierChange, waitAfterChangeMs) {
|
|
23195
|
+
return afterTierChange ? now + waitAfterChangeMs : now;
|
|
23196
|
+
}
|
|
23197
|
+
/**
|
|
23198
|
+
* Bucket width. 100 ms, and edges round to the NEAREST boundary, so a frame is
|
|
23199
|
+
* put on the wrong side of a window edge only when it was sent within 50 ms of
|
|
23200
|
+
* it — about one frame at 25 fps. Rounding edges inward instead dropped a whole
|
|
23201
|
+
* IDR that sat 160 ms inside a window and read a whole rung as 1.5× delivered.
|
|
23202
|
+
* 32 s of history is 320 slots.
|
|
23203
|
+
*/
|
|
23204
|
+
var SEND_BUCKET_MS = 100;
|
|
23205
|
+
/**
|
|
23206
|
+
* Bytes this session sent, bucketed by time. A fixed ring: `record` is on the
|
|
23207
|
+
* RTP send path, so it allocates nothing.
|
|
23208
|
+
*/
|
|
23209
|
+
var SendHistory = class {
|
|
23210
|
+
bytes;
|
|
23211
|
+
/** The bucket index each slot currently holds; -1 = empty. */
|
|
23212
|
+
ids;
|
|
23213
|
+
constructor(spanMs) {
|
|
23214
|
+
const slots = Math.max(1, Math.ceil(spanMs / SEND_BUCKET_MS));
|
|
23215
|
+
this.bytes = new Float64Array(slots);
|
|
23216
|
+
this.ids = new Float64Array(slots).fill(-1);
|
|
23217
|
+
}
|
|
23218
|
+
record(byteCount, now) {
|
|
23219
|
+
const id = Math.floor(now / SEND_BUCKET_MS);
|
|
23220
|
+
const slot = id % this.ids.length;
|
|
23221
|
+
if (this.ids[slot] !== id) {
|
|
23222
|
+
this.ids[slot] = id;
|
|
23223
|
+
this.bytes[slot] = 0;
|
|
23224
|
+
}
|
|
23225
|
+
this.bytes[slot] = (this.bytes[slot] ?? 0) + byteCount;
|
|
23226
|
+
}
|
|
23227
|
+
bitsBetween(fromMs, toMs) {
|
|
23228
|
+
const first = Math.round(fromMs / SEND_BUCKET_MS);
|
|
23229
|
+
const last = Math.round(toMs / SEND_BUCKET_MS) - 1;
|
|
23230
|
+
let bytes = 0;
|
|
23231
|
+
for (let id = first; id <= last; id += 1) {
|
|
23232
|
+
const slot = id % this.ids.length;
|
|
23233
|
+
if (this.ids[slot] === id) bytes += this.bytes[slot] ?? 0;
|
|
23234
|
+
}
|
|
23235
|
+
return bytes * 8;
|
|
23236
|
+
}
|
|
23237
|
+
};
|
|
23238
|
+
/**
|
|
23239
|
+
* The client's recent reports, and the ratio they imply against a
|
|
23240
|
+
* {@link SendHistoryReader}. One meter per adaptive session: `notBefore` is
|
|
23241
|
+
* the session's start, because the reports are DEVICE-scoped and a report
|
|
23242
|
+
* about time before this session describes the previous one (a re-offer).
|
|
23243
|
+
*/
|
|
23244
|
+
var DeliveryMeter = class {
|
|
23245
|
+
config;
|
|
23246
|
+
notBefore;
|
|
23247
|
+
reports = [];
|
|
23248
|
+
constructor(config, notBefore) {
|
|
23249
|
+
this.config = config;
|
|
23250
|
+
this.notBefore = notBefore;
|
|
23251
|
+
}
|
|
23252
|
+
/** Fold one client report. The same report read on several ticks counts once. */
|
|
23253
|
+
observe(kbps, endAt) {
|
|
23254
|
+
if (!Number.isFinite(kbps) || kbps < 0 || !Number.isFinite(endAt)) return;
|
|
23255
|
+
if (endAt - this.config.clientSampleSpanMs < this.notBefore) return;
|
|
23256
|
+
if (this.reports.some((r) => r.endAt === endAt)) return;
|
|
23257
|
+
const horizon = endAt - this.config.historySpanMs;
|
|
23258
|
+
this.reports = [...this.reports.filter((r) => r.endAt > horizon), {
|
|
23259
|
+
kbps,
|
|
23260
|
+
endAt
|
|
23261
|
+
}].sort((a, b) => a.endAt - b.endAt);
|
|
23262
|
+
}
|
|
23263
|
+
/**
|
|
23264
|
+
* Is the client reporting at all? A fresh report that does not yet cover a
|
|
23265
|
+
* whole window is "not measured YET" — different from a client that sends
|
|
23266
|
+
* no telemetry, and it must not be climbed on (a 10 %-delivery link would
|
|
23267
|
+
* otherwise promote in the seconds before its first window closes).
|
|
23268
|
+
*/
|
|
23269
|
+
isReporting(now) {
|
|
23270
|
+
const newest = this.reports.at(-1);
|
|
23271
|
+
return newest !== void 0 && now - newest.endAt <= this.config.maxClientSampleAgeMs;
|
|
23272
|
+
}
|
|
23273
|
+
/**
|
|
23274
|
+
* The newest report alone against the same 2 s of sends — the short window
|
|
23275
|
+
* a total freeze is judged on. Null when there is none, or it is stale.
|
|
23276
|
+
*/
|
|
23277
|
+
evaluateNewest(history, now) {
|
|
23278
|
+
const newest = this.reports.at(-1);
|
|
23279
|
+
if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
|
|
23280
|
+
const span = this.config.clientSampleSpanMs;
|
|
23281
|
+
return {
|
|
23282
|
+
receivedKbps: newest.kbps,
|
|
23283
|
+
sentKbps: history.bitsBetween(newest.endAt - span, newest.endAt) / span,
|
|
23284
|
+
windowMs: span
|
|
23285
|
+
};
|
|
23286
|
+
}
|
|
23287
|
+
/**
|
|
23288
|
+
* Received vs sent over the newest reports that together cover `windowMs`,
|
|
23289
|
+
* or `null` when they do not (yet) — or the newest one is stale.
|
|
23290
|
+
*/
|
|
23291
|
+
evaluate(history, windowMs, now) {
|
|
23292
|
+
const newest = this.reports.at(-1);
|
|
23293
|
+
if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
|
|
23294
|
+
const span = this.config.clientSampleSpanMs;
|
|
23295
|
+
let covered = 0;
|
|
23296
|
+
let receivedBits = 0;
|
|
23297
|
+
let sentBits = 0;
|
|
23298
|
+
for (let i = this.reports.length - 1; i >= 0 && covered < windowMs; i -= 1) {
|
|
23299
|
+
const r = this.reports[i];
|
|
23300
|
+
if (r === void 0 || now - (r.endAt - span) > this.config.historySpanMs) break;
|
|
23301
|
+
covered += span;
|
|
23302
|
+
receivedBits += r.kbps * span;
|
|
23303
|
+
sentBits += history.bitsBetween(r.endAt - span, r.endAt);
|
|
23304
|
+
}
|
|
23305
|
+
if (covered < windowMs) return null;
|
|
23306
|
+
return {
|
|
23307
|
+
receivedKbps: receivedBits / covered,
|
|
23308
|
+
sentKbps: sentBits / covered,
|
|
23309
|
+
windowMs: covered
|
|
23310
|
+
};
|
|
23311
|
+
}
|
|
23312
|
+
};
|
|
23313
|
+
//#endregion
|
|
23314
|
+
//#region src/stream-broker/webrtc/adaptive-config.ts
|
|
23315
|
+
/** How often the adaptive controller re-evaluates the ladder (ms). */
|
|
23316
|
+
var ADAPTIVE_TICK_MS = 2e3;
|
|
23317
|
+
/** Ladder tuning shared by every adaptive session. */
|
|
23318
|
+
var ADAPTIVE_CONFIG = {
|
|
23319
|
+
analyzeWindowMs: 15e3,
|
|
23320
|
+
minFailPercent: .05,
|
|
23321
|
+
waitAfterResetMs: 8e3,
|
|
23322
|
+
upgradeHealthyMs: 4e3,
|
|
23323
|
+
upgradeHealthyGrowthFactor: 3,
|
|
23324
|
+
maxProgressiveUpgradeHealthyMs: 3e4,
|
|
23325
|
+
minTimeBetweenChangesMs: 4e3,
|
|
23326
|
+
maxUpgradeBackoffMs: 9e5,
|
|
23327
|
+
stallDeliveryRatio: .25,
|
|
23328
|
+
recoverDeliveryRatio: .5,
|
|
23329
|
+
freezeDeliveryRatio: .1,
|
|
23330
|
+
freezeMinSentKbps: 16,
|
|
23331
|
+
climbDeliveryRatio: .9,
|
|
23332
|
+
stallConfirmMs: 6e3,
|
|
23333
|
+
waitAfterChangeMs: 8e3,
|
|
23334
|
+
climbCapacityMargin: 1.2,
|
|
23335
|
+
tryClimbDeliveryRatio: .95,
|
|
23336
|
+
tryClimbMaxLossRatio: .01,
|
|
23337
|
+
tryBackoffBaseMs: 3e4,
|
|
23338
|
+
tryBackoffMaxMs: 6e5,
|
|
23339
|
+
trialMs: 2e4,
|
|
23340
|
+
trialMaxMs: 4e4,
|
|
23341
|
+
trialRevertRatio: .9,
|
|
23342
|
+
trialRevertConsecutive: 2
|
|
23343
|
+
};
|
|
23344
|
+
/** How the delivery window is built from the client's reports. */
|
|
23345
|
+
var ADAPTIVE_DELIVERY_CONFIG = {
|
|
23346
|
+
clientSampleSpanMs: 2e3,
|
|
23347
|
+
minWindowMs: 2e3,
|
|
23348
|
+
maxWindowMs: 16e3,
|
|
23349
|
+
maxClientSampleAgeMs: 1e4,
|
|
23350
|
+
historySpanMs: 32e3
|
|
23351
|
+
};
|
|
23352
|
+
//#endregion
|
|
23353
|
+
//#region src/stream-broker/webrtc/climb-capacity.ts
|
|
23354
|
+
/** The client ladder's thresholds (`DOWNLINK_MID_MBPS`, `DOWNLINK_HIGH_MBPS`), kbps. */
|
|
23355
|
+
var UNMEASURED_RUNG_BAR_KBPS = {
|
|
23356
|
+
low: 0,
|
|
23357
|
+
mid: 2500,
|
|
23358
|
+
high: 8e3
|
|
23359
|
+
};
|
|
23360
|
+
/** The kbps the next rung needs; `(1800 × 1.2)` or the client threshold. */
|
|
23361
|
+
function capacityBarKbps(tier, rungKbps, margin) {
|
|
23362
|
+
return rungKbps !== null && rungKbps > 0 ? rungKbps * margin : UNMEASURED_RUNG_BAR_KBPS[tier];
|
|
23363
|
+
}
|
|
23364
|
+
function known(v) {
|
|
23365
|
+
return v !== null && Number.isFinite(v) && v > 0;
|
|
23366
|
+
}
|
|
23367
|
+
function judgeCapacity(evidence, nextTier, nextRungKbps, margin) {
|
|
23368
|
+
const neededKbps = capacityBarKbps(nextTier, nextRungKbps, margin);
|
|
23369
|
+
const { rembKbps, probeKbps, probeFresh } = evidence;
|
|
23370
|
+
if (known(probeKbps) && probeFresh && probeKbps >= neededKbps) return {
|
|
23371
|
+
kind: "sufficient",
|
|
23372
|
+
source: "probe",
|
|
23373
|
+
kbps: probeKbps,
|
|
23374
|
+
neededKbps
|
|
23375
|
+
};
|
|
23376
|
+
if (known(rembKbps) && rembKbps >= neededKbps) return {
|
|
23377
|
+
kind: "sufficient",
|
|
23378
|
+
source: "remb",
|
|
23379
|
+
kbps: rembKbps,
|
|
23380
|
+
neededKbps
|
|
23381
|
+
};
|
|
23382
|
+
if (known(probeKbps) && probeKbps < neededKbps) return {
|
|
23383
|
+
kind: "probe-short",
|
|
23384
|
+
kbps: probeKbps,
|
|
23385
|
+
neededKbps
|
|
23386
|
+
};
|
|
23387
|
+
return {
|
|
23388
|
+
kind: "none",
|
|
23389
|
+
bestKbps: known(rembKbps) ? rembKbps : null,
|
|
23390
|
+
neededKbps
|
|
23391
|
+
};
|
|
23392
|
+
}
|
|
23393
|
+
/**
|
|
23394
|
+
* REMB as it can be trusted. werift stores the LAST received estimate on the
|
|
23395
|
+
* sender and never ages it, so a peer that stopped sending REMB (backgrounded,
|
|
23396
|
+
* gone) would vouch for room indefinitely (review m5). Chrome re-estimates
|
|
23397
|
+
* about once a second and the value moves; an estimate unchanged for
|
|
23398
|
+
* `staleMs` (30 000 ms — fifteen ticks) is treated as no estimate at all.
|
|
23399
|
+
*/
|
|
23400
|
+
var RembWatch = class {
|
|
23401
|
+
staleMs;
|
|
23402
|
+
last = null;
|
|
23403
|
+
changedAt = 0;
|
|
23404
|
+
constructor(staleMs) {
|
|
23405
|
+
this.staleMs = staleMs;
|
|
23406
|
+
}
|
|
23407
|
+
read(kbps, now) {
|
|
23408
|
+
if (kbps === null) {
|
|
23409
|
+
this.last = null;
|
|
23410
|
+
return null;
|
|
23411
|
+
}
|
|
23412
|
+
if (kbps !== this.last) {
|
|
23413
|
+
this.last = kbps;
|
|
23414
|
+
this.changedAt = now;
|
|
23415
|
+
}
|
|
23416
|
+
return now - this.changedAt > this.staleMs ? null : kbps;
|
|
23417
|
+
}
|
|
23418
|
+
};
|
|
23419
|
+
//#endregion
|
|
23180
23420
|
//#region src/stream-broker/webrtc/transcode-egress-leg.ts
|
|
23181
23421
|
/**
|
|
23182
23422
|
* The request a browser session makes. Structured only: the primitive's schema
|
|
@@ -23291,6 +23531,8 @@ function selectAudioClock(mimeType) {
|
|
|
23291
23531
|
*
|
|
23292
23532
|
* Uses werift (optional peer dependency) for server-side WebRTC.
|
|
23293
23533
|
*/
|
|
23534
|
+
/** A REMB unchanged this long is no estimate (`RembWatch`). */
|
|
23535
|
+
var REMB_STALE_MS = 3e4;
|
|
23294
23536
|
var _werift;
|
|
23295
23537
|
async function loadWerift() {
|
|
23296
23538
|
if (_werift) return _werift;
|
|
@@ -24812,6 +25054,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
24812
25054
|
this.maybeRebaseRtp(pkt.header.sequenceNumber, pkt.header.timestamp);
|
|
24813
25055
|
try {
|
|
24814
25056
|
this.videoSender.sendRtp(pkt);
|
|
25057
|
+
this.videoSendHistory.record(pkt.payload.length, Date.now());
|
|
24815
25058
|
this.lastVideoRtpTs = pkt.header.timestamp >>> 0;
|
|
24816
25059
|
if (this.firstVideoRtpTs === null) {
|
|
24817
25060
|
this.firstVideoRtpTs = this.lastVideoRtpTs;
|
|
@@ -25383,6 +25626,26 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
25383
25626
|
get rtpPacketsSentTotal() {
|
|
25384
25627
|
return this.rtpPacketsSent;
|
|
25385
25628
|
}
|
|
25629
|
+
/**
|
|
25630
|
+
* Video RTP PAYLOAD bytes this session sent, bucketed by time — the "sent"
|
|
25631
|
+
* half of the adaptive ladder's delivery ratio (D635). Payload, not wire
|
|
25632
|
+
* size, because the client's `bytesReceived` excludes RTP headers.
|
|
25633
|
+
*/
|
|
25634
|
+
videoSendHistory = new SendHistory(ADAPTIVE_DELIVERY_CONFIG.historySpanMs);
|
|
25635
|
+
get sentVideoHistory() {
|
|
25636
|
+
return this.videoSendHistory;
|
|
25637
|
+
}
|
|
25638
|
+
/**
|
|
25639
|
+
* The receiver's bandwidth estimate (REMB) in kbps, or `null` when the peer
|
|
25640
|
+
* has sent none. werift stores the last REMB on the sender; `-1`/absent in
|
|
25641
|
+
* the loss line means the same "no estimate", which is not "zero".
|
|
25642
|
+
*/
|
|
25643
|
+
receiverEstimateKbps(now) {
|
|
25644
|
+
const raw = this.videoSender?.receiverEstimatedMaxBitrate;
|
|
25645
|
+
return this.rembWatch.read(typeof raw === "bigint" && raw > 0n ? Number(raw) / 1e3 : null, now);
|
|
25646
|
+
}
|
|
25647
|
+
/** Ages the REMB werift keeps forever — see `RembWatch`. */
|
|
25648
|
+
rembWatch = new RembWatch(REMB_STALE_MS);
|
|
25386
25649
|
rtpLogCounter = 0;
|
|
25387
25650
|
/** One-shot guards for the "first video/audio RTP" debug markers. */
|
|
25388
25651
|
firstVideoRtpLogged = false;
|
|
@@ -25705,6 +25968,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
25705
25968
|
} });
|
|
25706
25969
|
}
|
|
25707
25970
|
this.videoSender.sendRtp(pkt);
|
|
25971
|
+
this.videoSendHistory.record(payload.length, Date.now());
|
|
25708
25972
|
this.rtpPacketsSent++;
|
|
25709
25973
|
this.logFirstVideoRtp("writeVideoNals", codec);
|
|
25710
25974
|
if (this.debug && (this.rtpPacketsSent === 1 || this.rtpPacketsSent % 500 === 0)) this.logger.info("RTP packets sent", { meta: {
|
|
@@ -26172,6 +26436,42 @@ var LossTracker = class {
|
|
|
26172
26436
|
//#endregion
|
|
26173
26437
|
//#region src/stream-broker/webrtc/stall-tracker.ts
|
|
26174
26438
|
/**
|
|
26439
|
+
* The one place the device-scoped telemetry is judged ownable. With more than
|
|
26440
|
+
* one adaptive session of the device, the record may be another viewer's:
|
|
26441
|
+
* delivery is unknown ("no telemetry" semantics — the loss path judges) and
|
|
26442
|
+
* the sample says so, so the ladder refuses a try-climb on it.
|
|
26443
|
+
*/
|
|
26444
|
+
function buildStallSample(input) {
|
|
26445
|
+
if (input.adaptiveSessionsOnDevice > 1) return {
|
|
26446
|
+
delivery: null,
|
|
26447
|
+
recent: null,
|
|
26448
|
+
clientReporting: false,
|
|
26449
|
+
telemetryShared: true,
|
|
26450
|
+
serverSending: input.serverSending
|
|
26451
|
+
};
|
|
26452
|
+
return {
|
|
26453
|
+
delivery: input.delivery,
|
|
26454
|
+
recent: input.recent,
|
|
26455
|
+
clientReporting: input.clientReporting,
|
|
26456
|
+
telemetryShared: false,
|
|
26457
|
+
serverSending: input.serverSending
|
|
26458
|
+
};
|
|
26459
|
+
}
|
|
26460
|
+
var UNKNOWN_EVIDENCE = {
|
|
26461
|
+
clientKbps: null,
|
|
26462
|
+
sentKbps: null,
|
|
26463
|
+
deliveryRatio: null,
|
|
26464
|
+
deliveryWindowMs: null,
|
|
26465
|
+
stalledForMs: 0,
|
|
26466
|
+
serverSending: false
|
|
26467
|
+
};
|
|
26468
|
+
function ratioOf(delivery) {
|
|
26469
|
+
if (delivery === null) return null;
|
|
26470
|
+
const { receivedKbps, sentKbps } = delivery;
|
|
26471
|
+
if (!Number.isFinite(receivedKbps) || !Number.isFinite(sentKbps) || sentKbps <= 0) return null;
|
|
26472
|
+
return receivedKbps / sentKbps;
|
|
26473
|
+
}
|
|
26474
|
+
/**
|
|
26175
26475
|
* Windowed "the media stopped arriving" detector. Pure state machine: no
|
|
26176
26476
|
* clock of its own, no side effects — `observe` is the only mutator and every
|
|
26177
26477
|
* decision is a function of the samples it was given.
|
|
@@ -26181,11 +26481,9 @@ var StallTracker = class {
|
|
|
26181
26481
|
/** When the current uninterrupted stall began; null when not stalling. */
|
|
26182
26482
|
stallStartedAt = null;
|
|
26183
26483
|
blackoutUntil = 0;
|
|
26184
|
-
lastEvidence =
|
|
26185
|
-
|
|
26186
|
-
|
|
26187
|
-
serverSending: false
|
|
26188
|
-
};
|
|
26484
|
+
lastEvidence = UNKNOWN_EVIDENCE;
|
|
26485
|
+
clientReporting = false;
|
|
26486
|
+
telemetryShared = false;
|
|
26189
26487
|
constructor(config) {
|
|
26190
26488
|
this.config = config;
|
|
26191
26489
|
}
|
|
@@ -26198,38 +26496,36 @@ var StallTracker = class {
|
|
|
26198
26496
|
* Fold one evaluation into the window. Returns nothing; ask `isStalled`.
|
|
26199
26497
|
*
|
|
26200
26498
|
* Three outcomes, and the middle one is the reason this is not a simple
|
|
26201
|
-
* boolean: a
|
|
26202
|
-
* below `
|
|
26203
|
-
* (or unknown, or
|
|
26204
|
-
*
|
|
26499
|
+
* boolean: a ratio at or above `recoverDeliveryRatio` CLEARS the stall, a
|
|
26500
|
+
* ratio below `stallDeliveryRatio` with the server sending starts or extends
|
|
26501
|
+
* it, and anything between the two (or unknown, or nothing sent) HOLDS
|
|
26502
|
+
* whatever the window already believed.
|
|
26205
26503
|
*/
|
|
26206
26504
|
observe(sample, now) {
|
|
26505
|
+
const ratio = ratioOf(sample.delivery);
|
|
26506
|
+
const measured = (stalledForMs) => ({
|
|
26507
|
+
clientKbps: sample.delivery?.receivedKbps ?? null,
|
|
26508
|
+
sentKbps: sample.delivery?.sentKbps ?? null,
|
|
26509
|
+
deliveryRatio: ratio,
|
|
26510
|
+
deliveryWindowMs: sample.delivery?.windowMs ?? null,
|
|
26511
|
+
stalledForMs,
|
|
26512
|
+
serverSending: sample.serverSending
|
|
26513
|
+
});
|
|
26514
|
+
this.clientReporting = sample.clientReporting;
|
|
26515
|
+
this.telemetryShared = sample.telemetryShared;
|
|
26207
26516
|
if (now < this.blackoutUntil) {
|
|
26208
26517
|
this.stallStartedAt = null;
|
|
26209
|
-
this.lastEvidence =
|
|
26210
|
-
clientKbps: sample.clientKbps,
|
|
26211
|
-
stalledForMs: 0,
|
|
26212
|
-
serverSending: sample.serverSending
|
|
26213
|
-
};
|
|
26518
|
+
this.lastEvidence = measured(0);
|
|
26214
26519
|
return;
|
|
26215
26520
|
}
|
|
26216
|
-
const
|
|
26217
|
-
const
|
|
26218
|
-
|
|
26219
|
-
|
|
26220
|
-
|
|
26221
|
-
|
|
26222
|
-
|
|
26223
|
-
|
|
26224
|
-
return;
|
|
26225
|
-
}
|
|
26226
|
-
if (kbps >= this.config.recoverKbpsFloor) this.stallStartedAt = null;
|
|
26227
|
-
else if (kbps < this.config.stallKbpsFloor && sample.serverSending) this.stallStartedAt ??= now;
|
|
26228
|
-
this.lastEvidence = {
|
|
26229
|
-
clientKbps: kbps,
|
|
26230
|
-
stalledForMs: this.stalledForMs(now),
|
|
26231
|
-
serverSending: sample.serverSending
|
|
26232
|
-
};
|
|
26521
|
+
const recent = sample.recent !== null && sample.recent.sentKbps >= this.config.freezeMinSentKbps ? ratioOf(sample.recent) : null;
|
|
26522
|
+
const frozen = recent !== null && recent < this.config.freezeDeliveryRatio;
|
|
26523
|
+
const recovered = recent !== null && recent >= this.config.recoverDeliveryRatio;
|
|
26524
|
+
const starved = ratio !== null && ratio < this.config.stallDeliveryRatio && !recovered;
|
|
26525
|
+
const current = recent ?? ratio;
|
|
26526
|
+
if ((frozen || starved) && sample.serverSending) this.stallStartedAt ??= now;
|
|
26527
|
+
else if (current !== null && current >= this.config.recoverDeliveryRatio) this.stallStartedAt = null;
|
|
26528
|
+
this.lastEvidence = measured(this.stalledForMs(now));
|
|
26233
26529
|
}
|
|
26234
26530
|
stalledForMs(now) {
|
|
26235
26531
|
return this.stallStartedAt === null ? 0 : now - this.stallStartedAt;
|
|
@@ -26240,25 +26536,42 @@ var StallTracker = class {
|
|
|
26240
26536
|
return this.stalledForMs(now) >= this.config.stallConfirmMs;
|
|
26241
26537
|
}
|
|
26242
26538
|
/**
|
|
26243
|
-
* May the ladder PROMOTE on this evaluation?
|
|
26539
|
+
* May the ladder PROMOTE on this evaluation, as far as the transport knows?
|
|
26244
26540
|
*
|
|
26245
|
-
*
|
|
26246
|
-
*
|
|
26247
|
-
*
|
|
26248
|
-
*
|
|
26249
|
-
* reports almost no loss. Same doctrine as `LossTracker.hasEvidence` — a
|
|
26250
|
-
* climb needs POSITIVE evidence, not the absence of a complaint.
|
|
26541
|
+
* No while a stall is building, and no while the rung we are on is not
|
|
26542
|
+
* landing (nearly) whole — promoting there spends bandwidth the session has
|
|
26543
|
+
* just been shown not to have, and the loss path cannot object: a stream
|
|
26544
|
+
* delivering almost nothing reports almost no loss.
|
|
26251
26545
|
*
|
|
26252
|
-
* Unknown
|
|
26546
|
+
* Unknown refuses only while the client IS reporting — the window is simply
|
|
26547
|
+
* not covered yet, and climbing then would promote a link measured at 10 %
|
|
26548
|
+
* a few seconds later. A deployment with no client telemetry at all must
|
|
26253
26549
|
* behave exactly as it did before this tracker existed; the loss path's own
|
|
26254
26550
|
* `hasEvidence` is the gate there.
|
|
26255
26551
|
*/
|
|
26256
|
-
|
|
26257
|
-
if (now < this.blackoutUntil) return true;
|
|
26258
|
-
if (this.stallStartedAt !== null) return
|
|
26259
|
-
|
|
26260
|
-
|
|
26261
|
-
|
|
26552
|
+
climbSafety(now) {
|
|
26553
|
+
if (now < this.blackoutUntil) return { safe: true };
|
|
26554
|
+
if (this.stallStartedAt !== null) return {
|
|
26555
|
+
safe: false,
|
|
26556
|
+
reason: "stall-building"
|
|
26557
|
+
};
|
|
26558
|
+
const ratio = this.lastEvidence.deliveryRatio;
|
|
26559
|
+
if (ratio === null) return this.clientReporting ? {
|
|
26560
|
+
safe: false,
|
|
26561
|
+
reason: "delivery"
|
|
26562
|
+
} : { safe: true };
|
|
26563
|
+
return ratio >= this.config.climbDeliveryRatio ? { safe: true } : {
|
|
26564
|
+
safe: false,
|
|
26565
|
+
reason: "delivery"
|
|
26566
|
+
};
|
|
26567
|
+
}
|
|
26568
|
+
/** Was the device's client record possibly another viewer's at the last evaluation? */
|
|
26569
|
+
get isTelemetryShared() {
|
|
26570
|
+
return this.telemetryShared;
|
|
26571
|
+
}
|
|
26572
|
+
/** Was the client reporting at the last evaluation (even if not yet covering a window)? */
|
|
26573
|
+
get isClientReporting() {
|
|
26574
|
+
return this.clientReporting;
|
|
26262
26575
|
}
|
|
26263
26576
|
/** What the last `observe` measured — for the switch's log line. */
|
|
26264
26577
|
evidence(now) {
|
|
@@ -26269,9 +26582,142 @@ var StallTracker = class {
|
|
|
26269
26582
|
}
|
|
26270
26583
|
};
|
|
26271
26584
|
//#endregion
|
|
26585
|
+
//#region src/stream-broker/webrtc/rtt-stability.ts
|
|
26586
|
+
/** 16 s — the longest delivery window, so both halves of a try look at the same span. */
|
|
26587
|
+
var RTT_WINDOW_MS = 16e3;
|
|
26588
|
+
var MIN_SAMPLES = 3;
|
|
26589
|
+
var MIN_SPREAD_MS = 20;
|
|
26590
|
+
var SPREAD_FRACTION = .5;
|
|
26591
|
+
var RttWindow = class {
|
|
26592
|
+
samples = [];
|
|
26593
|
+
observe(rttMs, now) {
|
|
26594
|
+
if (rttMs === null || !Number.isFinite(rttMs) || rttMs <= 0) return;
|
|
26595
|
+
this.samples = [...this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS), {
|
|
26596
|
+
rttMs,
|
|
26597
|
+
at: now
|
|
26598
|
+
}];
|
|
26599
|
+
}
|
|
26600
|
+
stability(now) {
|
|
26601
|
+
const recent = this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS);
|
|
26602
|
+
if (recent.length < MIN_SAMPLES) return "unknown";
|
|
26603
|
+
const values = recent.map((s) => s.rttMs);
|
|
26604
|
+
const min = Math.min(...values);
|
|
26605
|
+
return Math.max(...values) - min <= Math.max(MIN_SPREAD_MS, min * SPREAD_FRACTION) ? "stable" : "unstable";
|
|
26606
|
+
}
|
|
26607
|
+
};
|
|
26608
|
+
//#endregion
|
|
26609
|
+
//#region src/stream-broker/webrtc/try-climb.ts
|
|
26610
|
+
var NO_TRY_HISTORY = {
|
|
26611
|
+
failures: 0,
|
|
26612
|
+
notBefore: 0,
|
|
26613
|
+
lastRevert: null,
|
|
26614
|
+
trialFrom: null
|
|
26615
|
+
};
|
|
26616
|
+
/** Backoff after the Nth consecutive failed try: base × 2^(N−1), capped. */
|
|
26617
|
+
function tryBackoffMs(failures, baseMs, maxMs) {
|
|
26618
|
+
if (failures <= 0) return 0;
|
|
26619
|
+
return Math.min(baseMs * 2 ** (failures - 1), maxMs);
|
|
26620
|
+
}
|
|
26621
|
+
/** Watches one tried rung through its first window. Pure: fed, never reads a clock. */
|
|
26622
|
+
var TrialWatch = class {
|
|
26623
|
+
from;
|
|
26624
|
+
config;
|
|
26625
|
+
startedAt = null;
|
|
26626
|
+
lowStreak = 0;
|
|
26627
|
+
measured = 0;
|
|
26628
|
+
constructor(from, config) {
|
|
26629
|
+
this.from = from;
|
|
26630
|
+
this.config = config;
|
|
26631
|
+
}
|
|
26632
|
+
/**
|
|
26633
|
+
* Start the trial over: it must be judged afresh on live media. Called when
|
|
26634
|
+
* recorded playback takes the track (review m14) — time off live must never
|
|
26635
|
+
* count toward keeping a rung that was never measured.
|
|
26636
|
+
*/
|
|
26637
|
+
restart() {
|
|
26638
|
+
this.startedAt = null;
|
|
26639
|
+
this.lowStreak = 0;
|
|
26640
|
+
this.measured = 0;
|
|
26641
|
+
}
|
|
26642
|
+
judge(now, input) {
|
|
26643
|
+
this.startedAt ??= now;
|
|
26644
|
+
if (input.stalled || input.lossFailing) return {
|
|
26645
|
+
kind: "revert",
|
|
26646
|
+
deliveryRatio: input.deliveryRatio,
|
|
26647
|
+
stalled: input.stalled
|
|
26648
|
+
};
|
|
26649
|
+
if (input.deliveryRatio !== null) {
|
|
26650
|
+
this.measured += 1;
|
|
26651
|
+
this.lowStreak = input.deliveryRatio < this.config.trialRevertRatio ? this.lowStreak + 1 : 0;
|
|
26652
|
+
if (this.lowStreak >= this.config.trialRevertConsecutive) return {
|
|
26653
|
+
kind: "revert",
|
|
26654
|
+
deliveryRatio: input.deliveryRatio,
|
|
26655
|
+
stalled: false
|
|
26656
|
+
};
|
|
26657
|
+
}
|
|
26658
|
+
const forMs = now - this.startedAt;
|
|
26659
|
+
const judged = this.measured >= this.config.trialRevertConsecutive || !input.clientReporting;
|
|
26660
|
+
if (forMs >= this.config.trialMs && judged || forMs >= this.config.trialMaxMs) return { kind: "kept" };
|
|
26661
|
+
return {
|
|
26662
|
+
kind: "running",
|
|
26663
|
+
forMs
|
|
26664
|
+
};
|
|
26665
|
+
}
|
|
26666
|
+
};
|
|
26667
|
+
/**
|
|
26668
|
+
* The try-climb memory per DEVICE on this broker, with a TTL (review I4).
|
|
26669
|
+
* The per-session intent survives only a re-offer; without this, every new
|
|
26670
|
+
* viewer session of a camera whose next rung keeps failing started again at
|
|
26671
|
+
* try #1. The trial itself (`trialFrom`) never carries to a new session.
|
|
26672
|
+
*/
|
|
26673
|
+
var DeviceTryMemory = class {
|
|
26674
|
+
ttlMs;
|
|
26675
|
+
entries = /* @__PURE__ */ new Map();
|
|
26676
|
+
/** @param ttlMs forget a device not refreshed for this long — 600 000 ms, the backoff cap. */
|
|
26677
|
+
constructor(ttlMs) {
|
|
26678
|
+
this.ttlMs = ttlMs;
|
|
26679
|
+
}
|
|
26680
|
+
set(deviceId, carry, now) {
|
|
26681
|
+
this.entries.set(deviceId, {
|
|
26682
|
+
carry,
|
|
26683
|
+
at: now
|
|
26684
|
+
});
|
|
26685
|
+
}
|
|
26686
|
+
get(deviceId, now) {
|
|
26687
|
+
const entry = this.entries.get(deviceId);
|
|
26688
|
+
if (entry === void 0) return null;
|
|
26689
|
+
if (now - entry.at > this.ttlMs) {
|
|
26690
|
+
this.entries.delete(deviceId);
|
|
26691
|
+
return null;
|
|
26692
|
+
}
|
|
26693
|
+
return {
|
|
26694
|
+
...entry.carry,
|
|
26695
|
+
trialFrom: null
|
|
26696
|
+
};
|
|
26697
|
+
}
|
|
26698
|
+
};
|
|
26699
|
+
//#endregion
|
|
26272
26700
|
//#region src/stream-broker/webrtc/adaptive-controller.ts
|
|
26273
26701
|
var TIER_ORDER = CAM_PROFILE_ORDER;
|
|
26274
26702
|
/**
|
|
26703
|
+
* The cause as the wire (`WebrtcLadderDecision.cause`) can carry it. The wire
|
|
26704
|
+
* enum predates the try-climb, and widening it would make a server built
|
|
26705
|
+
* before it reject this broker's debug answer (the router validates output).
|
|
26706
|
+
* A trial revert is reported as what undelivered bytes are — `stall` when the
|
|
26707
|
+
* stall confirmed, else `loss` — and the climb hold carries the exact account
|
|
26708
|
+
* ("tried mid, reverted: delivery 0.60").
|
|
26709
|
+
*/
|
|
26710
|
+
function wireLadderCause(decision, stallConfirmMs) {
|
|
26711
|
+
if (decision.cause !== "trial-revert") return { cause: decision.cause };
|
|
26712
|
+
return {
|
|
26713
|
+
cause: decision.stall.stalledForMs >= stallConfirmMs ? "stall" : "loss",
|
|
26714
|
+
causeDetail: "trial-revert"
|
|
26715
|
+
};
|
|
26716
|
+
}
|
|
26717
|
+
function positiveOrNull(v) {
|
|
26718
|
+
return v !== null && Number.isFinite(v) && v > 0 ? v : null;
|
|
26719
|
+
}
|
|
26720
|
+
/**
|
|
26275
26721
|
* Ladder state machine for an adaptive WebRTC session. Fed loss samples
|
|
26276
26722
|
* (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
|
|
26277
26723
|
* Downgrade order: tier high→mid→low, then MTU reduction at low, then
|
|
@@ -26285,7 +26731,16 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
|
|
|
26285
26731
|
* a session that has produced no RTCP at all is not "healthy", it is
|
|
26286
26732
|
* silent, and climbing on silence spends bandwidth never shown to exist;
|
|
26287
26733
|
* 3. the target tier's re-attempt backoff, which doubles per measured
|
|
26288
|
-
* failure of that tier in this session
|
|
26734
|
+
* failure of that tier in this session;
|
|
26735
|
+
* 4. the transport showing the CURRENT rung landing whole
|
|
26736
|
+
* (`StallTracker.climbSafety`, a delivery ratio over one GOP — D635);
|
|
26737
|
+
* 5. capacity: evidence (REMB or the viewer's probe) clearing the next
|
|
26738
|
+
* rung's bar confirms the climb; a MEASURED probe below it holds; with no
|
|
26739
|
+
* evidence either way the ladder TRIES the rung on a clean path (delivery
|
|
26740
|
+
* ≥ 0.95, loss < 1 %, RTT stable) and reverts within the rung's first
|
|
26741
|
+
* window if it does not land, backing off 30 s → 10 min (D635, round 2).
|
|
26742
|
+
*
|
|
26743
|
+
* Every tick that does not promote leaves a {@link ClimbHold} naming why.
|
|
26289
26744
|
*/
|
|
26290
26745
|
var AdaptiveController = class {
|
|
26291
26746
|
opts;
|
|
@@ -26306,6 +26761,16 @@ var AdaptiveController = class {
|
|
|
26306
26761
|
armed = false;
|
|
26307
26762
|
failures;
|
|
26308
26763
|
upgradeCount;
|
|
26764
|
+
capacity = {
|
|
26765
|
+
rembKbps: null,
|
|
26766
|
+
probeKbps: null,
|
|
26767
|
+
probeFresh: false
|
|
26768
|
+
};
|
|
26769
|
+
rtt = new RttWindow();
|
|
26770
|
+
tryCarry;
|
|
26771
|
+
/** Non-null while the current rung is a trial. */
|
|
26772
|
+
trial;
|
|
26773
|
+
hold = null;
|
|
26309
26774
|
constructor(opts) {
|
|
26310
26775
|
this.opts = opts;
|
|
26311
26776
|
this.tracker = new LossTracker(opts.config);
|
|
@@ -26313,6 +26778,16 @@ var AdaptiveController = class {
|
|
|
26313
26778
|
this.tier = opts.initialTier;
|
|
26314
26779
|
this.failures = new Map(opts.priorFailures ?? []);
|
|
26315
26780
|
this.upgradeCount = Math.max(0, opts.priorUpgradeCount ?? 0);
|
|
26781
|
+
const carried = opts.tryClimb ?? NO_TRY_HISTORY;
|
|
26782
|
+
this.tryCarry = carried.trialFrom === opts.initialTier ? {
|
|
26783
|
+
...carried,
|
|
26784
|
+
trialFrom: null
|
|
26785
|
+
} : carried;
|
|
26786
|
+
this.trial = this.tryCarry.trialFrom !== null ? new TrialWatch(this.tryCarry.trialFrom, opts.config) : null;
|
|
26787
|
+
}
|
|
26788
|
+
/** What the NEXT controller of this viewer session must inherit about tries. */
|
|
26789
|
+
get tryClimbCarry() {
|
|
26790
|
+
return this.tryCarry;
|
|
26316
26791
|
}
|
|
26317
26792
|
get currentTier() {
|
|
26318
26793
|
return this.tier;
|
|
@@ -26345,6 +26820,27 @@ var AdaptiveController = class {
|
|
|
26345
26820
|
this.stalls.observe(sample, now);
|
|
26346
26821
|
}
|
|
26347
26822
|
/**
|
|
26823
|
+
* The capacity witnesses, kbps — `null` when unknown: the receiver's REMB
|
|
26824
|
+
* (capped by what is sent, so evidence only FOR room) and the viewer's
|
|
26825
|
+
* downlink probe for this session (a path measurement, evidence both ways).
|
|
26826
|
+
* See `climb-capacity.ts`.
|
|
26827
|
+
*/
|
|
26828
|
+
onCapacity(evidence) {
|
|
26829
|
+
this.capacity = {
|
|
26830
|
+
rembKbps: positiveOrNull(evidence.rembKbps),
|
|
26831
|
+
probeKbps: positiveOrNull(evidence.probeKbps),
|
|
26832
|
+
probeFresh: evidence.probeFresh
|
|
26833
|
+
};
|
|
26834
|
+
}
|
|
26835
|
+
/** The client's round-trip time, ms; null/0 = unknown. One of a try's conditions. */
|
|
26836
|
+
onRtt(rttMs, now) {
|
|
26837
|
+
this.rtt.observe(rttMs, now);
|
|
26838
|
+
}
|
|
26839
|
+
/** Why the ladder did not climb on the last evaluation; null = at the top, or it just moved. */
|
|
26840
|
+
get climbHold() {
|
|
26841
|
+
return this.hold;
|
|
26842
|
+
}
|
|
26843
|
+
/**
|
|
26348
26844
|
* Freeze the ladder for this evaluation instead of ticking it.
|
|
26349
26845
|
*
|
|
26350
26846
|
* Called while the session's track is NOT carrying the live source — D42
|
|
@@ -26365,6 +26861,8 @@ var AdaptiveController = class {
|
|
|
26365
26861
|
this.lastChangeAt = now;
|
|
26366
26862
|
}
|
|
26367
26863
|
this.healthySince = now;
|
|
26864
|
+
this.trial?.restart();
|
|
26865
|
+
this.setHold("suspended", now);
|
|
26368
26866
|
this.stalls.markChanged(now);
|
|
26369
26867
|
}
|
|
26370
26868
|
tick(now) {
|
|
@@ -26373,26 +26871,132 @@ var AdaptiveController = class {
|
|
|
26373
26871
|
this.healthySince = now;
|
|
26374
26872
|
this.lastChangeAt = now;
|
|
26375
26873
|
}
|
|
26874
|
+
if (this.trial !== null && this.judgeTrial(now)) return;
|
|
26376
26875
|
if (this.stalls.isStalled(now)) {
|
|
26377
26876
|
this.healthySince = now;
|
|
26877
|
+
this.hold = null;
|
|
26378
26878
|
this.handleFailing(now, "stall");
|
|
26379
26879
|
return;
|
|
26380
26880
|
}
|
|
26381
26881
|
if (this.tracker.isFailing(now)) {
|
|
26382
26882
|
this.healthySince = now;
|
|
26883
|
+
this.hold = null;
|
|
26383
26884
|
this.handleFailing(now, "loss");
|
|
26384
26885
|
return;
|
|
26385
26886
|
}
|
|
26386
|
-
|
|
26887
|
+
const safety = this.stalls.climbSafety(now);
|
|
26888
|
+
if (!safety.safe) {
|
|
26387
26889
|
this.healthySince = now;
|
|
26890
|
+
this.setHold(safety.reason, now);
|
|
26388
26891
|
return;
|
|
26389
26892
|
}
|
|
26390
26893
|
if (!this.tracker.hasEvidence(now)) {
|
|
26391
26894
|
this.healthySince = now;
|
|
26895
|
+
this.setHold("no-evidence", now);
|
|
26392
26896
|
return;
|
|
26393
26897
|
}
|
|
26394
26898
|
this.handleHealthy(now);
|
|
26395
26899
|
}
|
|
26900
|
+
nextTier() {
|
|
26901
|
+
const idx = TIER_ORDER.indexOf(this.tier);
|
|
26902
|
+
return idx <= 0 ? null : TIER_ORDER[idx - 1] ?? null;
|
|
26903
|
+
}
|
|
26904
|
+
/**
|
|
26905
|
+
* Record why this evaluation did not climb. At the top rung there is nothing
|
|
26906
|
+
* to hold — except a `trial`, which names the rung being tried.
|
|
26907
|
+
*/
|
|
26908
|
+
setHold(code, now, extra = {}) {
|
|
26909
|
+
const next = code === "trial" ? this.tier : this.nextTier();
|
|
26910
|
+
if (next === null) {
|
|
26911
|
+
this.hold = null;
|
|
26912
|
+
return;
|
|
26913
|
+
}
|
|
26914
|
+
const ev = this.stalls.evidence(now);
|
|
26915
|
+
const clockRunning = code === "warming" || code === "cooldown" || code === "capacity" || code === "try-unclean" || code === "try-backoff";
|
|
26916
|
+
const nextTierKbps = this.opts.expectedTierKbps?.(next) ?? null;
|
|
26917
|
+
this.hold = {
|
|
26918
|
+
code,
|
|
26919
|
+
nextTier: next,
|
|
26920
|
+
deliveryRatio: ev.deliveryRatio,
|
|
26921
|
+
deliveryWindowMs: ev.deliveryWindowMs,
|
|
26922
|
+
climbDeliveryRatio: this.opts.config.climbDeliveryRatio,
|
|
26923
|
+
capacityKbps: this.capacity.rembKbps,
|
|
26924
|
+
probeKbps: this.capacity.probeKbps,
|
|
26925
|
+
nextTierKbps,
|
|
26926
|
+
capacityMargin: this.opts.config.climbCapacityMargin,
|
|
26927
|
+
neededKbps: capacityBarKbps(next, nextTierKbps, this.opts.config.climbCapacityMargin),
|
|
26928
|
+
tryBlockedBy: extra.tryBlockedBy ?? null,
|
|
26929
|
+
tryDeliveryRatio: this.opts.config.tryClimbDeliveryRatio,
|
|
26930
|
+
tryMaxLossRatio: this.opts.config.tryClimbMaxLossRatio,
|
|
26931
|
+
retryInMs: code === "try-backoff" ? Math.max(0, this.tryCarry.notBefore - now) : null,
|
|
26932
|
+
lastRevert: this.tryCarry.lastRevert,
|
|
26933
|
+
trialForMs: code === "trial" ? extra.trialForMs ?? 0 : null,
|
|
26934
|
+
trialMs: code === "trial" ? this.opts.config.trialMs : null,
|
|
26935
|
+
healthyForMs: clockRunning ? now - this.healthySince : null,
|
|
26936
|
+
requiredHealthyMs: this.requiredHealthyMs(next),
|
|
26937
|
+
since: this.hold?.code === code ? this.hold.since : now
|
|
26938
|
+
};
|
|
26939
|
+
}
|
|
26940
|
+
/**
|
|
26941
|
+
* Judge the rung on trial. Returns true when this evaluation is consumed
|
|
26942
|
+
* (still running, or reverted); false when the trial was just kept and the
|
|
26943
|
+
* ordinary ladder takes over.
|
|
26944
|
+
*/
|
|
26945
|
+
judgeTrial(now) {
|
|
26946
|
+
const watch = this.trial;
|
|
26947
|
+
if (watch === null) return false;
|
|
26948
|
+
const verdict = watch.judge(now, {
|
|
26949
|
+
deliveryRatio: this.stalls.evidence(now).deliveryRatio,
|
|
26950
|
+
clientReporting: this.stalls.isClientReporting,
|
|
26951
|
+
stalled: this.stalls.isStalled(now),
|
|
26952
|
+
lossFailing: this.tracker.isFailing(now)
|
|
26953
|
+
});
|
|
26954
|
+
if (verdict.kind === "running") {
|
|
26955
|
+
this.healthySince = now;
|
|
26956
|
+
this.setHold("trial", now, { trialForMs: verdict.forMs });
|
|
26957
|
+
return true;
|
|
26958
|
+
}
|
|
26959
|
+
this.trial = null;
|
|
26960
|
+
if (verdict.kind === "kept") {
|
|
26961
|
+
this.tryCarry = NO_TRY_HISTORY;
|
|
26962
|
+
this.healthySince = now;
|
|
26963
|
+
return false;
|
|
26964
|
+
}
|
|
26965
|
+
const failures = this.tryCarry.failures + 1;
|
|
26966
|
+
const tried = this.tier;
|
|
26967
|
+
this.tryCarry = {
|
|
26968
|
+
failures,
|
|
26969
|
+
notBefore: now + tryBackoffMs(failures, this.opts.config.tryBackoffBaseMs, this.opts.config.tryBackoffMaxMs),
|
|
26970
|
+
lastRevert: {
|
|
26971
|
+
tier: tried,
|
|
26972
|
+
deliveryRatio: verdict.deliveryRatio,
|
|
26973
|
+
stalled: verdict.stalled,
|
|
26974
|
+
at: now
|
|
26975
|
+
},
|
|
26976
|
+
trialFrom: null
|
|
26977
|
+
};
|
|
26978
|
+
this.hold = null;
|
|
26979
|
+
this.tier = watch.from;
|
|
26980
|
+
this.mtuReduced = false;
|
|
26981
|
+
this.transcodeEngaged = false;
|
|
26982
|
+
this.opts.onSwitchTier(watch.from, this.decisionAt(now, "trial-revert"));
|
|
26983
|
+
this.commitChange(now);
|
|
26984
|
+
this.healthySince = now;
|
|
26985
|
+
return true;
|
|
26986
|
+
}
|
|
26987
|
+
/**
|
|
26988
|
+
* Which clean-path condition refuses a TRY, or null when the path is clean.
|
|
26989
|
+
* A client that sends no telemetry at all cannot measure delivery; there the
|
|
26990
|
+
* loss path is the judge, as it was before delivery existed.
|
|
26991
|
+
*/
|
|
26992
|
+
tryBlocker(now) {
|
|
26993
|
+
if (this.stalls.isTelemetryShared) return "shared-telemetry";
|
|
26994
|
+
const ratio = this.stalls.evidence(now).deliveryRatio;
|
|
26995
|
+
if (ratio === null ? this.stalls.isClientReporting : ratio < this.opts.config.tryClimbDeliveryRatio) return "delivery";
|
|
26996
|
+
if (this.tracker.lossRatio() >= this.opts.config.tryClimbMaxLossRatio) return "loss";
|
|
26997
|
+
if (this.rtt.stability(now) === "unstable") return "rtt";
|
|
26998
|
+
return null;
|
|
26999
|
+
}
|
|
26396
27000
|
canUpgrade(now) {
|
|
26397
27001
|
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
|
|
26398
27002
|
}
|
|
@@ -26413,11 +27017,12 @@ var AdaptiveController = class {
|
|
|
26413
27017
|
this.stalls.markChanged(now);
|
|
26414
27018
|
}
|
|
26415
27019
|
/** The measurement this decision rests on, snapshotted for the log line. */
|
|
26416
|
-
decisionAt(now, cause) {
|
|
27020
|
+
decisionAt(now, cause, trial = false) {
|
|
26417
27021
|
return {
|
|
26418
27022
|
cause,
|
|
26419
27023
|
lossRatio: this.tracker.lossRatio(),
|
|
26420
|
-
stall: this.stalls.evidence(now)
|
|
27024
|
+
stall: this.stalls.evidence(now),
|
|
27025
|
+
trial
|
|
26421
27026
|
};
|
|
26422
27027
|
}
|
|
26423
27028
|
handleFailing(now, cause) {
|
|
@@ -26444,21 +27049,223 @@ var AdaptiveController = class {
|
|
|
26444
27049
|
}
|
|
26445
27050
|
}
|
|
26446
27051
|
handleHealthy(now) {
|
|
26447
|
-
const
|
|
26448
|
-
if (
|
|
26449
|
-
|
|
26450
|
-
|
|
26451
|
-
|
|
27052
|
+
const next = this.nextTier();
|
|
27053
|
+
if (next === null) {
|
|
27054
|
+
this.hold = null;
|
|
27055
|
+
return;
|
|
27056
|
+
}
|
|
27057
|
+
const capacity = judgeCapacity(this.capacity, next, this.opts.expectedTierKbps?.(next) ?? null, this.opts.config.climbCapacityMargin);
|
|
27058
|
+
if (capacity.kind === "probe-short") {
|
|
27059
|
+
this.setHold("capacity", now);
|
|
27060
|
+
return;
|
|
27061
|
+
}
|
|
27062
|
+
if (capacity.kind === "none" && now < this.tryCarry.notBefore) {
|
|
27063
|
+
this.setHold("try-backoff", now);
|
|
27064
|
+
return;
|
|
27065
|
+
}
|
|
27066
|
+
if (now - this.healthySince < this.requiredHealthyMs(next)) {
|
|
27067
|
+
this.setHold("warming", now);
|
|
27068
|
+
return;
|
|
27069
|
+
}
|
|
27070
|
+
if (!this.canUpgrade(now)) {
|
|
27071
|
+
this.setHold("cooldown", now);
|
|
27072
|
+
return;
|
|
27073
|
+
}
|
|
27074
|
+
if (capacity.kind === "sufficient") {
|
|
27075
|
+
this.promote(now, next, false);
|
|
27076
|
+
return;
|
|
27077
|
+
}
|
|
27078
|
+
const blocker = this.tryBlocker(now);
|
|
27079
|
+
if (blocker !== null) {
|
|
27080
|
+
this.setHold("try-unclean", now, { tryBlockedBy: blocker });
|
|
27081
|
+
return;
|
|
27082
|
+
}
|
|
27083
|
+
this.promote(now, next, true);
|
|
27084
|
+
}
|
|
27085
|
+
promote(now, next, trial) {
|
|
27086
|
+
const from = this.tier;
|
|
27087
|
+
this.hold = null;
|
|
26452
27088
|
this.tier = next;
|
|
26453
27089
|
this.mtuReduced = false;
|
|
26454
27090
|
this.transcodeEngaged = false;
|
|
26455
27091
|
this.upgradeCount += 1;
|
|
26456
|
-
this.
|
|
27092
|
+
this.tryCarry = {
|
|
27093
|
+
...this.tryCarry,
|
|
27094
|
+
trialFrom: trial ? from : null
|
|
27095
|
+
};
|
|
27096
|
+
this.trial = trial ? new TrialWatch(from, this.opts.config) : null;
|
|
27097
|
+
this.opts.onSwitchTier(next, this.decisionAt(now, "healthy", trial));
|
|
26457
27098
|
this.commitChange(now);
|
|
26458
27099
|
this.healthySince = now;
|
|
26459
27100
|
}
|
|
26460
27101
|
};
|
|
26461
27102
|
//#endregion
|
|
27103
|
+
//#region src/stream-broker/webrtc/climb-hold.ts
|
|
27104
|
+
/** The wire shape: the hold plus the line the broker logs. */
|
|
27105
|
+
function toWireClimbHold(h) {
|
|
27106
|
+
return {
|
|
27107
|
+
...h,
|
|
27108
|
+
summary: describeClimbHold(h)
|
|
27109
|
+
};
|
|
27110
|
+
}
|
|
27111
|
+
function seconds(ms) {
|
|
27112
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
27113
|
+
}
|
|
27114
|
+
/** A rate, or "unknown" — never a fabricated 0 kbps (D393). */
|
|
27115
|
+
function kbps(v) {
|
|
27116
|
+
return v === null ? "unknown" : `${String(Math.round(v))} kbps`;
|
|
27117
|
+
}
|
|
27118
|
+
function deliveryPart(h) {
|
|
27119
|
+
if (h.deliveryRatio === null || h.deliveryWindowMs === null) return null;
|
|
27120
|
+
return `delivery ${h.deliveryRatio.toFixed(2)} over ${seconds(h.deliveryWindowMs)}`;
|
|
27121
|
+
}
|
|
27122
|
+
/** `mid needs 2160 kbps (1800 × 1.2)`, or `(client threshold)` for an unmeasured rung. */
|
|
27123
|
+
function barPart(h) {
|
|
27124
|
+
const basis = h.nextTierKbps !== null && h.nextTierKbps > 0 ? `(${String(Math.round(h.nextTierKbps))} × ${String(h.capacityMargin)})` : "(client threshold)";
|
|
27125
|
+
return `${h.nextTier} needs ${kbps(h.neededKbps)} ${basis}`;
|
|
27126
|
+
}
|
|
27127
|
+
function witnesses(h) {
|
|
27128
|
+
return `${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`}, ${h.probeKbps === null ? "no probe" : `probe ${kbps(h.probeKbps)}`}`;
|
|
27129
|
+
}
|
|
27130
|
+
function blockerPart(h) {
|
|
27131
|
+
switch (h.tryBlockedBy) {
|
|
27132
|
+
case "delivery": return h.deliveryRatio === null ? "delivery not measured" : `delivery ${h.deliveryRatio.toFixed(2)} < ${h.tryDeliveryRatio.toFixed(2)}`;
|
|
27133
|
+
case "loss": return `loss ≥ ${String(Math.round(h.tryMaxLossRatio * 1e3) / 10)}%`;
|
|
27134
|
+
case "rtt": return "RTT unstable";
|
|
27135
|
+
case "shared-telemetry": return "another viewer shares this camera's telemetry";
|
|
27136
|
+
case null: return "unknown";
|
|
27137
|
+
}
|
|
27138
|
+
}
|
|
27139
|
+
function revertPart(h) {
|
|
27140
|
+
const r = h.lastRevert;
|
|
27141
|
+
if (r === null) return "a try reverted";
|
|
27142
|
+
const why = r.stalled ? "stall" : r.deliveryRatio === null ? "loss" : `delivery ${r.deliveryRatio.toFixed(2)}`;
|
|
27143
|
+
return `tried ${r.tier}, reverted: ${why}`;
|
|
27144
|
+
}
|
|
27145
|
+
/**
|
|
27146
|
+
* One line, English, numbers included — the same text the broker logs and the
|
|
27147
|
+
* viewer shows under its localized hold label. Every number is one the
|
|
27148
|
+
* controller actually used; an unknown one is said to be unknown.
|
|
27149
|
+
*/
|
|
27150
|
+
function describeClimbHold(h) {
|
|
27151
|
+
const delivery = deliveryPart(h);
|
|
27152
|
+
switch (h.code) {
|
|
27153
|
+
case "stall-building": return `held: stall building (${delivery ?? "delivery unknown"})`;
|
|
27154
|
+
case "delivery": return delivery === null ? "held: delivery not yet measured over one GOP" : `held: ${delivery} < ${h.climbDeliveryRatio.toFixed(2)}`;
|
|
27155
|
+
case "no-evidence": return "held: no receiver feedback in the loss window";
|
|
27156
|
+
case "warming": return `climbing: healthy ${seconds(h.healthyForMs ?? 0)} of ${seconds(h.requiredHealthyMs ?? 0)} before ${h.nextTier}`;
|
|
27157
|
+
case "cooldown": return "held: too soon after the last change";
|
|
27158
|
+
case "capacity": return `held: probe ${kbps(h.probeKbps)}, ${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`} < ${barPart(h)}`;
|
|
27159
|
+
case "try-unclean": return `held: no capacity evidence for ${h.nextTier} (${witnesses(h)}); not clean enough to try: ${blockerPart(h)}`;
|
|
27160
|
+
case "try-backoff": return `held: ${revertPart(h)}; next try in ${seconds(h.retryInMs ?? 0)}`;
|
|
27161
|
+
case "trial": return `trying ${h.nextTier}: ${seconds(h.trialForMs ?? 0)} of ${seconds(h.trialMs ?? 0)} (reverts if it does not land)`;
|
|
27162
|
+
case "suspended": return "held: recorded playback on the track (ladder suspended)";
|
|
27163
|
+
}
|
|
27164
|
+
}
|
|
27165
|
+
/**
|
|
27166
|
+
* Which hold changes deserve a log line (review m3). A code logs when it
|
|
27167
|
+
* differs from the last one logged AND has not itself been logged within
|
|
27168
|
+
* `quietMs` — a link hovering at 0.90–0.95 delivery cycles delivery →
|
|
27169
|
+
* warming → try-unclean and used to write a line every 4–8 s for ever.
|
|
27170
|
+
* Progress (`warming`, `trial`) never logs; the switch line covers moves.
|
|
27171
|
+
*/
|
|
27172
|
+
var HoldLogGate = class {
|
|
27173
|
+
quietMs;
|
|
27174
|
+
last = null;
|
|
27175
|
+
loggedAt = /* @__PURE__ */ new Map();
|
|
27176
|
+
constructor(quietMs) {
|
|
27177
|
+
this.quietMs = quietMs;
|
|
27178
|
+
}
|
|
27179
|
+
shouldLog(code, now) {
|
|
27180
|
+
if (code === "warming" || code === "trial") return false;
|
|
27181
|
+
if (code === this.last) return false;
|
|
27182
|
+
const at = this.loggedAt.get(code);
|
|
27183
|
+
if (at !== void 0 && now - at < this.quietMs) return false;
|
|
27184
|
+
this.last = code;
|
|
27185
|
+
this.loggedAt.set(code, now);
|
|
27186
|
+
return true;
|
|
27187
|
+
}
|
|
27188
|
+
};
|
|
27189
|
+
//#endregion
|
|
27190
|
+
//#region src/stream-broker/webrtc/rung-bitrate.ts
|
|
27191
|
+
/**
|
|
27192
|
+
* Shortest lifetime whose average means anything: 4 000 ms ≈ one GOP on the
|
|
27193
|
+
* cameras this hub serves; less than that is one IDR divided by nothing.
|
|
27194
|
+
*/
|
|
27195
|
+
var MIN_AVERAGE_UPTIME_MS = 4e3;
|
|
27196
|
+
function expectedRungKbps(stats) {
|
|
27197
|
+
const lifetime = stats.totalBytes > 0 && stats.uptimeMs >= MIN_AVERAGE_UPTIME_MS ? Math.round(stats.totalBytes * 8 / stats.uptimeMs) : null;
|
|
27198
|
+
const windowKbps = stats.status === "streaming" && stats.bitrateKbps > 0 ? stats.bitrateKbps : null;
|
|
27199
|
+
if (windowKbps === null) return lifetime;
|
|
27200
|
+
return lifetime === null ? windowKbps : Math.max(windowKbps, lifetime);
|
|
27201
|
+
}
|
|
27202
|
+
/** The client's probe cache TTL (`DOWNLINK_TTL_MS`): the oldest a hinted probe can be. */
|
|
27203
|
+
var HINT_PROBE_MAX_AGE_MS = 3e4;
|
|
27204
|
+
function mbpsToKbps(mbps) {
|
|
27205
|
+
return mbps !== void 0 && Number.isFinite(mbps) && mbps > 0 ? mbps * 1e3 : null;
|
|
27206
|
+
}
|
|
27207
|
+
/** The probe an offer carried, dated conservatively; null when it carried none. */
|
|
27208
|
+
function probeFromHints(downlinkMbps, now) {
|
|
27209
|
+
const kbps = mbpsToKbps(downlinkMbps);
|
|
27210
|
+
return kbps === null ? null : {
|
|
27211
|
+
kbps,
|
|
27212
|
+
at: now - HINT_PROBE_MAX_AGE_MS
|
|
27213
|
+
};
|
|
27214
|
+
}
|
|
27215
|
+
/** The probe a telemetry report carried; null when it carried none. */
|
|
27216
|
+
function probeFromReport(report, now) {
|
|
27217
|
+
const kbps = mbpsToKbps(report.downlinkMbps);
|
|
27218
|
+
if (kbps === null) return null;
|
|
27219
|
+
return {
|
|
27220
|
+
kbps,
|
|
27221
|
+
at: now - Math.max(0, report.reportAgeMs) - Math.max(0, report.downlinkAgeMs)
|
|
27222
|
+
};
|
|
27223
|
+
}
|
|
27224
|
+
/** The more recent of two probes (either may be absent). */
|
|
27225
|
+
function newerProbe(a, b) {
|
|
27226
|
+
if (a === null) return b;
|
|
27227
|
+
if (b === null) return a;
|
|
27228
|
+
return b.at >= a.at ? b : a;
|
|
27229
|
+
}
|
|
27230
|
+
function isProbeFresh(probe, now) {
|
|
27231
|
+
return probe !== null && now - probe.at <= 12e4;
|
|
27232
|
+
}
|
|
27233
|
+
/**
|
|
27234
|
+
* How long the broker keeps a device's probe at all. 600 000 ms (10 min, the
|
|
27235
|
+
* try-backoff cap). A SHORT probe holds a climb until a newer probe replaces
|
|
27236
|
+
* it — and a viewer that cannot probe (no token yet, a share link, whose token
|
|
27237
|
+
* is not session-grade) never supplies one, so without an end one viewer's
|
|
27238
|
+
* 1.2 Mbps reading would hold every such viewer of the camera on `capacity`
|
|
27239
|
+
* until the broker restarted (review N1).
|
|
27240
|
+
*/
|
|
27241
|
+
var PROBE_MEMORY_TTL_MS = 6e5;
|
|
27242
|
+
/** The newest probe per device, bounded in time and forgotten with the device's last session. */
|
|
27243
|
+
var DeviceProbeMemory = class {
|
|
27244
|
+
ttlMs;
|
|
27245
|
+
probes = /* @__PURE__ */ new Map();
|
|
27246
|
+
constructor(ttlMs) {
|
|
27247
|
+
this.ttlMs = ttlMs;
|
|
27248
|
+
}
|
|
27249
|
+
/** Keep `probe` if it is newer than what is held; an absent one changes nothing. */
|
|
27250
|
+
record(deviceId, probe) {
|
|
27251
|
+
const kept = newerProbe(this.probes.get(deviceId) ?? null, probe);
|
|
27252
|
+
if (kept !== null) this.probes.set(deviceId, kept);
|
|
27253
|
+
}
|
|
27254
|
+
get(deviceId, now) {
|
|
27255
|
+
const probe = this.probes.get(deviceId);
|
|
27256
|
+
if (probe === void 0) return null;
|
|
27257
|
+
if (now - probe.at > this.ttlMs) {
|
|
27258
|
+
this.probes.delete(deviceId);
|
|
27259
|
+
return null;
|
|
27260
|
+
}
|
|
27261
|
+
return probe;
|
|
27262
|
+
}
|
|
27263
|
+
/** The device's last adaptive session ended: nobody's probe speaks for the next viewer. */
|
|
27264
|
+
forget(deviceId) {
|
|
27265
|
+
this.probes.delete(deviceId);
|
|
27266
|
+
}
|
|
27267
|
+
};
|
|
27268
|
+
//#endregion
|
|
26462
27269
|
//#region src/stream-broker/webrtc/session-debug.ts
|
|
26463
27270
|
/** `<deviceId>/<camStreamId>` → the camStreamId, or null when it is not one. */
|
|
26464
27271
|
function camStreamIdOf(brokerId) {
|
|
@@ -26497,7 +27304,8 @@ var DEAD = {
|
|
|
26497
27304
|
trackMode: null,
|
|
26498
27305
|
lastDecision: null,
|
|
26499
27306
|
tierFailures: [],
|
|
26500
|
-
upgradeCount: null
|
|
27307
|
+
upgradeCount: null,
|
|
27308
|
+
climbHold: null
|
|
26501
27309
|
};
|
|
26502
27310
|
function projectSessionDebug(input) {
|
|
26503
27311
|
const { session } = input;
|
|
@@ -26534,7 +27342,8 @@ function projectSessionDebug(input) {
|
|
|
26534
27342
|
tier,
|
|
26535
27343
|
failures
|
|
26536
27344
|
})),
|
|
26537
|
-
upgradeCount: input.ladder?.upgradeCount ?? null
|
|
27345
|
+
upgradeCount: input.ladder?.upgradeCount ?? null,
|
|
27346
|
+
climbHold: input.ladder?.climbHold ?? null
|
|
26538
27347
|
};
|
|
26539
27348
|
}
|
|
26540
27349
|
var PLAYBACK_PROFILE_FALLBACK_ORDER = [
|
|
@@ -26560,6 +27369,22 @@ function gapReachable(gap, epochMs) {
|
|
|
26560
27369
|
}
|
|
26561
27370
|
//#endregion
|
|
26562
27371
|
//#region src/stream-broker/recorded/control-protocol.ts
|
|
27372
|
+
/**
|
|
27373
|
+
* The rates a clip may be OFFERED at — re-exported from the capability, which
|
|
27374
|
+
* is where the one list lives (D612). The broker owns the clamp; the cap owns
|
|
27375
|
+
* the ladder; a second list here would be the second authority D62 forbids.
|
|
27376
|
+
*
|
|
27377
|
+
* Declared at the top of the file because the clamp and the `setRate` bound
|
|
27378
|
+
* are DERIVED from its ends (D621). They were three literals — this array, a
|
|
27379
|
+
* `Math.min(4, …)` and a `.max(4)` — and that is precisely how 8 and 16 came
|
|
27380
|
+
* to be offered while the clamp served 4 with no line anywhere saying so. One
|
|
27381
|
+
* decision now has one place to be written down.
|
|
27382
|
+
*/
|
|
27383
|
+
var BROKER_PACED_RATES = CLIP_BROKER_PACED_RATES;
|
|
27384
|
+
/** The slowest offered rate. Anything slower is clamped up to it. */
|
|
27385
|
+
var MIN_PACED_RATE = Math.min(...BROKER_PACED_RATES);
|
|
27386
|
+
/** The fastest offered rate. Anything faster is refused at the wire. */
|
|
27387
|
+
var MAX_PACED_RATE = Math.max(...BROKER_PACED_RATES);
|
|
26563
27388
|
var ControlMessageSchema = discriminatedUnion("t", [
|
|
26564
27389
|
object({
|
|
26565
27390
|
t: literal("playRecorded"),
|
|
@@ -26626,7 +27451,7 @@ var ControlMessageSchema = discriminatedUnion("t", [
|
|
|
26626
27451
|
}),
|
|
26627
27452
|
object({
|
|
26628
27453
|
t: literal("setRate"),
|
|
26629
|
-
rate: number().min(0).max(
|
|
27454
|
+
rate: number().min(0).max(MAX_PACED_RATE)
|
|
26630
27455
|
}),
|
|
26631
27456
|
object({
|
|
26632
27457
|
t: literal("stepFrame"),
|
|
@@ -26639,22 +27464,41 @@ var ControlMessageSchema = discriminatedUnion("t", [
|
|
|
26639
27464
|
})
|
|
26640
27465
|
]);
|
|
26641
27466
|
/**
|
|
26642
|
-
* Clamp a requested playback rate onto the supported domain:
|
|
26643
|
-
* 0
|
|
26644
|
-
*
|
|
26645
|
-
*
|
|
27467
|
+
* Clamp a requested playback rate onto the supported domain:
|
|
27468
|
+
* {0} ∪ [{@link MIN_PACED_RATE}, {@link MAX_PACED_RATE}] — the ends of
|
|
27469
|
+
* {@link BROKER_PACED_RATES} itself. 0 (and any non-finite / non-positive
|
|
27470
|
+
* input) pauses; anything else is a continuous rate clamped into that band.
|
|
27471
|
+
* Single server-side authority — the feeder applies it to every inbound
|
|
27472
|
+
* `setRate`.
|
|
27473
|
+
*
|
|
27474
|
+
* That every ladder entry survives this unchanged is now structural rather
|
|
27475
|
+
* than a discipline. What is NOT structural, and is therefore the gate that
|
|
27476
|
+
* matters, is whether the pacer really empties a clip `rate` times faster:
|
|
27477
|
+
* `clip-stream-feeder.spec` drives every entry and measures it.
|
|
26646
27478
|
*/
|
|
26647
27479
|
function clampPlaybackRate(rate) {
|
|
26648
27480
|
if (!Number.isFinite(rate) || rate <= 0) return 0;
|
|
26649
|
-
return Math.min(
|
|
27481
|
+
return Math.min(MAX_PACED_RATE, Math.max(MIN_PACED_RATE, rate));
|
|
26650
27482
|
}
|
|
26651
27483
|
/**
|
|
26652
|
-
*
|
|
26653
|
-
*
|
|
26654
|
-
*
|
|
26655
|
-
* `
|
|
27484
|
+
* Does a clip carry SOUND at this rate?
|
|
27485
|
+
*
|
|
27486
|
+
* ONE predicate, read by the branch that DROPS the audio
|
|
27487
|
+
* (`ClipStreamFeeder.pushNextAudio`) and by the branch that TELLS the viewer
|
|
27488
|
+
* why it is silent — because a second copy of `rate !== 1` is exactly the
|
|
27489
|
+
* disagreeing second switch D62 forbids, and the telling would then be able
|
|
27490
|
+
* to describe a drop that did not happen (or miss one that did).
|
|
27491
|
+
*
|
|
27492
|
+
* *"L'audio non serve in velocità non 1x!!!"* — the operator, 2026-09-23.
|
|
27493
|
+
* Resampling would need a decoder this path has not got, and pitch-shifted
|
|
27494
|
+
* sound is worse than none.
|
|
27495
|
+
*
|
|
27496
|
+
* Rate 0 is PAUSE, not a rate: nothing plays at all there, so it is not this
|
|
27497
|
+
* predicate's business and callers hold their last non-zero rate instead.
|
|
26656
27498
|
*/
|
|
26657
|
-
|
|
27499
|
+
function audioPlaysAtRate(rate) {
|
|
27500
|
+
return rate === 1;
|
|
27501
|
+
}
|
|
26658
27502
|
var ServerMessageSchema = discriminatedUnion("t", [
|
|
26659
27503
|
object({
|
|
26660
27504
|
t: literal("state"),
|
|
@@ -26695,6 +27539,14 @@ var ServerMessageSchema = discriminatedUnion("t", [
|
|
|
26695
27539
|
clipId: string().min(1),
|
|
26696
27540
|
durationMs: number().positive()
|
|
26697
27541
|
}),
|
|
27542
|
+
object({
|
|
27543
|
+
t: literal("clipIncomplete"),
|
|
27544
|
+
clipId: string().min(1),
|
|
27545
|
+
code: _enum(["stream-short", "stream-empty"]),
|
|
27546
|
+
/** The provider's own sentence, already Latin-1 safe. Shown where the
|
|
27547
|
+
* stream error is shown. */
|
|
27548
|
+
reason: string().min(1)
|
|
27549
|
+
}),
|
|
26698
27550
|
object({
|
|
26699
27551
|
t: literal("clipAudio"),
|
|
26700
27552
|
clipId: string().min(1),
|
|
@@ -26707,8 +27559,23 @@ var ServerMessageSchema = discriminatedUnion("t", [
|
|
|
26707
27559
|
"no-audio-track",
|
|
26708
27560
|
"unsupported-codec",
|
|
26709
27561
|
"codec-mismatch",
|
|
26710
|
-
"stream-failed"
|
|
26711
|
-
|
|
27562
|
+
"stream-failed",
|
|
27563
|
+
"rate"
|
|
27564
|
+
]).optional(),
|
|
27565
|
+
/**
|
|
27566
|
+
* The rate the sound is suppressed AT. Present iff `reason` is `rate`,
|
|
27567
|
+
* and refused otherwise — see the check below.
|
|
27568
|
+
*/
|
|
27569
|
+
rate: number().positive().optional()
|
|
27570
|
+
}).check((ctx) => {
|
|
27571
|
+
const value = ctx.value;
|
|
27572
|
+
if (value.reason === "rate" === (value.rate !== void 0)) return;
|
|
27573
|
+
ctx.issues.push({
|
|
27574
|
+
code: "custom",
|
|
27575
|
+
message: "`rate` is present exactly when `reason` is `rate`",
|
|
27576
|
+
input: value,
|
|
27577
|
+
path: ["rate"]
|
|
27578
|
+
});
|
|
26712
27579
|
})
|
|
26713
27580
|
]);
|
|
26714
27581
|
function parseControlMessage(raw) {
|
|
@@ -27680,7 +28547,7 @@ var RecordedFeeder = class {
|
|
|
27680
28547
|
/** Audio clock (PCMU 8 kHz / Opus 48 kHz); re-anchored together with video. */
|
|
27681
28548
|
audioClock;
|
|
27682
28549
|
clockHasAdvanced = false;
|
|
27683
|
-
/** Playback rate: 0 (paused) or a continuous factor in [0.25,
|
|
28550
|
+
/** Playback rate: 0 (paused) or a continuous factor in [0.25, 8]. */
|
|
27684
28551
|
rate = 1;
|
|
27685
28552
|
disposed = false;
|
|
27686
28553
|
seekToken = 0;
|
|
@@ -28261,7 +29128,7 @@ var RecordedFeeder = class {
|
|
|
28261
29128
|
}
|
|
28262
29129
|
/**
|
|
28263
29130
|
* Set playback rate. 0 pauses in place (the pacing loop aborts on its next
|
|
28264
|
-
* guard check); any other value is clamped to [0.25,
|
|
29131
|
+
* guard check); any other value is clamped to [0.25, 8].
|
|
28265
29132
|
*
|
|
28266
29133
|
* Rate-change strategy (documented choice): every transition TO a non-zero
|
|
28267
29134
|
* rate — resume from pause AND a playing→playing rate change — re-enters via
|
|
@@ -30126,17 +30993,25 @@ var TimelineSession = class {
|
|
|
30126
30993
|
this.lastPositionMs = null;
|
|
30127
30994
|
const feeder = this.enterRecorded();
|
|
30128
30995
|
await feeder.seek(offsetMs);
|
|
30129
|
-
await Promise.all([
|
|
30996
|
+
await Promise.all([
|
|
30997
|
+
this.reportClipDuration(clipId, feeder),
|
|
30998
|
+
this.reportClipAudio(clipId, feeder),
|
|
30999
|
+
this.reportClipIncomplete(clipId, feeder)
|
|
31000
|
+
]);
|
|
30130
31001
|
}
|
|
30131
31002
|
/**
|
|
30132
31003
|
* Tell the viewer whether this clip has sound.
|
|
30133
31004
|
*
|
|
30134
|
-
* Silence has
|
|
30135
|
-
*
|
|
30136
|
-
*
|
|
30137
|
-
*
|
|
30138
|
-
* that
|
|
30139
|
-
*
|
|
31005
|
+
* Silence has causes the operator cannot tell apart by listening — a camera
|
|
31006
|
+
* with no microphone, a transport that carried no audio, a session that
|
|
31007
|
+
* negotiated none, and (D621) a clip playing at anything but 1×, where the
|
|
31008
|
+
* broker drops every frame on purpose — so the viewer is told which. Every
|
|
31009
|
+
* branch that sends nothing says so (D391): a feeder that cannot answer, an
|
|
31010
|
+
* answer that rejected, and an answer that landed after the pointer had left
|
|
31011
|
+
* the clip, which must not be pinned on whatever came next.
|
|
31012
|
+
*
|
|
31013
|
+
* The first answer SETTLES; the rate does not, so this also subscribes for
|
|
31014
|
+
* as long as the clip is on the pointer.
|
|
30140
31015
|
*/
|
|
30141
31016
|
async reportClipAudio(clipId, feeder) {
|
|
30142
31017
|
if (feeder.audio === void 0) {
|
|
@@ -30157,11 +31032,25 @@ var TimelineSession = class {
|
|
|
30157
31032
|
});
|
|
30158
31033
|
return;
|
|
30159
31034
|
}
|
|
31035
|
+
this.sendClipAudio(clipId, report);
|
|
31036
|
+
feeder.watchAudio?.((later) => this.sendClipAudio(clipId, later));
|
|
31037
|
+
}
|
|
31038
|
+
/**
|
|
31039
|
+
* Send one `clipAudio`, or say why nothing was sent.
|
|
31040
|
+
*
|
|
31041
|
+
* ONE place, for the settle and for every later change: they are the same
|
|
31042
|
+
* statement about the same clip, and a second builder is how the two would
|
|
31043
|
+
* come to disagree about what `reason` means. The clip-identity guard lives
|
|
31044
|
+
* here too — a report that lands after the pointer has left names a clip
|
|
31045
|
+
* nobody is playing, and the viewer would mute whatever came next.
|
|
31046
|
+
*/
|
|
31047
|
+
sendClipAudio(clipId, report) {
|
|
30160
31048
|
if (this.clip?.clipId !== clipId) {
|
|
30161
31049
|
this.deps.onLog?.("clip playback: audio dropped — the pointer has left this clip", {
|
|
30162
31050
|
deviceId: this.deps.deviceId,
|
|
30163
31051
|
clipId,
|
|
30164
|
-
carried: report.carried
|
|
31052
|
+
carried: report.carried,
|
|
31053
|
+
reason: report.reason
|
|
30165
31054
|
});
|
|
30166
31055
|
return;
|
|
30167
31056
|
}
|
|
@@ -30171,6 +31060,7 @@ var TimelineSession = class {
|
|
|
30171
31060
|
carried: report.carried,
|
|
30172
31061
|
codec: report.codec,
|
|
30173
31062
|
reason: report.reason,
|
|
31063
|
+
rate: report.rate,
|
|
30174
31064
|
detail: report.detail
|
|
30175
31065
|
});
|
|
30176
31066
|
this.deps.session.sendControl(serializeServerMessage({
|
|
@@ -30178,7 +31068,63 @@ var TimelineSession = class {
|
|
|
30178
31068
|
clipId,
|
|
30179
31069
|
carried: report.carried,
|
|
30180
31070
|
...report.codec !== null ? { codec: report.codec } : {},
|
|
30181
|
-
...report.reason !== null ? { reason: report.reason } : {}
|
|
31071
|
+
...report.reason !== null ? { reason: report.reason } : {},
|
|
31072
|
+
...report.reason === "rate" && report.rate !== void 0 ? { rate: report.rate } : {}
|
|
31073
|
+
}));
|
|
31074
|
+
}
|
|
31075
|
+
/**
|
|
31076
|
+
* Tell the operator that the clip he is watching did not arrive whole
|
|
31077
|
+
* (D623), in the surface that already shows him a stream error.
|
|
31078
|
+
*
|
|
31079
|
+
* Awaited AFTER the stream ends, which is when the provider gets to speak,
|
|
31080
|
+
* and guarded by the clip identity for the same reason the audio report is:
|
|
31081
|
+
* a late answer names a clip nobody is playing and the viewer would put the
|
|
31082
|
+
* complaint on whatever came next.
|
|
31083
|
+
*
|
|
31084
|
+
* There is no retry here and there must not be one. *"Se cade la sessione
|
|
31085
|
+
* cade non fa niente"* — the operator, 2026-09-23. A second dial would
|
|
31086
|
+
* contend for the camera's single replay lane exactly while that lane is
|
|
31087
|
+
* already failing.
|
|
31088
|
+
*/
|
|
31089
|
+
async reportClipIncomplete(clipId, feeder) {
|
|
31090
|
+
if (feeder.incomplete === void 0) {
|
|
31091
|
+
this.deps.onLog?.("clip playback: completeness not reported — this feeder cannot ask the provider", {
|
|
31092
|
+
deviceId: this.deps.deviceId,
|
|
31093
|
+
clipId
|
|
31094
|
+
});
|
|
31095
|
+
return;
|
|
31096
|
+
}
|
|
31097
|
+
let report;
|
|
31098
|
+
try {
|
|
31099
|
+
report = await feeder.incomplete();
|
|
31100
|
+
} catch (err) {
|
|
31101
|
+
this.deps.onLog?.("clip playback: completeness not reported — the answer failed", {
|
|
31102
|
+
deviceId: this.deps.deviceId,
|
|
31103
|
+
clipId,
|
|
31104
|
+
error: err instanceof Error ? err.message : String(err)
|
|
31105
|
+
});
|
|
31106
|
+
return;
|
|
31107
|
+
}
|
|
31108
|
+
if (report === null) return;
|
|
31109
|
+
if (this.clip?.clipId !== clipId) {
|
|
31110
|
+
this.deps.onLog?.("clip playback: completeness dropped — the pointer has left this clip", {
|
|
31111
|
+
deviceId: this.deps.deviceId,
|
|
31112
|
+
clipId,
|
|
31113
|
+
code: report.code
|
|
31114
|
+
});
|
|
31115
|
+
return;
|
|
31116
|
+
}
|
|
31117
|
+
this.deps.onLog?.("clip playback: the clip did not arrive whole", {
|
|
31118
|
+
deviceId: this.deps.deviceId,
|
|
31119
|
+
clipId,
|
|
31120
|
+
code: report.code,
|
|
31121
|
+
reason: report.reason
|
|
31122
|
+
});
|
|
31123
|
+
this.deps.session.sendControl(serializeServerMessage({
|
|
31124
|
+
t: "clipIncomplete",
|
|
31125
|
+
clipId,
|
|
31126
|
+
code: report.code,
|
|
31127
|
+
reason: report.reason
|
|
30182
31128
|
}));
|
|
30183
31129
|
}
|
|
30184
31130
|
/**
|
|
@@ -31580,7 +32526,7 @@ function createFmp4StreamDemuxer(options = {}) {
|
|
|
31580
32526
|
* faster than the camera sends.
|
|
31581
32527
|
* - `stepFrame(-1)` and the scrub verbs are refused by name; `stepFrame(+1)`
|
|
31582
32528
|
* pushes the next buffered unit.
|
|
31583
|
-
* - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25,
|
|
32529
|
+
* - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 8],
|
|
31584
32530
|
* the recorded domain). The camera's own `playSpeed` (8, 16 — real, the app
|
|
31585
32531
|
* reopens the replay at 8) is a different mechanism and is NOT wired: it
|
|
31586
32532
|
* would go on the dial (`videoclips.dialClipStream` → the cmd-5 replay's
|
|
@@ -31646,6 +32592,11 @@ function createFmp4StreamDemuxer(options = {}) {
|
|
|
31646
32592
|
* `data`, which the broker's `onLog` puts in `tags`.
|
|
31647
32593
|
*/
|
|
31648
32594
|
/**
|
|
32595
|
+
* The reason an in-flight open is abandoned, carried on the abort so the
|
|
32596
|
+
* opener's refusal and the provider's log line say the same thing.
|
|
32597
|
+
*/
|
|
32598
|
+
var CLIP_STREAM_ABANDONED = "clip-stream-abandoned";
|
|
32599
|
+
/**
|
|
31649
32600
|
* The same ceiling as the file path (`CLIP_SOURCE_MAX_BYTES`): a clip the
|
|
31650
32601
|
* file path would refuse `too-large` is one this path stops taking. Frames
|
|
31651
32602
|
* behind the head are released as they are pushed, so the backlog is the
|
|
@@ -31668,6 +32619,8 @@ var ClipStreamFeeder = class {
|
|
|
31668
32619
|
deps;
|
|
31669
32620
|
phase = "idle";
|
|
31670
32621
|
connection = null;
|
|
32622
|
+
/** Armed for the length of one `open()`; aborted by {@link teardown}. */
|
|
32623
|
+
opening = null;
|
|
31671
32624
|
demuxer;
|
|
31672
32625
|
clock = new MonotonicClock();
|
|
31673
32626
|
/** The RTP rate the audio sender negotiated — a CONVERSION, not a clock. */
|
|
@@ -31680,10 +32633,28 @@ var ClipStreamFeeder = class {
|
|
|
31680
32633
|
audioPushed = 0;
|
|
31681
32634
|
audioDroppedLate = 0;
|
|
31682
32635
|
audioDroppedRate = 0;
|
|
32636
|
+
/**
|
|
32637
|
+
* The last report handed to a caller — the settle promise's value, or the
|
|
32638
|
+
* newest one {@link watchAudio}'s listener was given. What a change is
|
|
32639
|
+
* measured against, so the same answer is never announced twice.
|
|
32640
|
+
*/
|
|
32641
|
+
audioEmitted = null;
|
|
32642
|
+
/**
|
|
32643
|
+
* The last NON-ZERO rate. Pause is the absence of a rate, not a rate: a
|
|
32644
|
+
* `clipAudio` blaming the speed while the clip is stopped would be false,
|
|
32645
|
+
* and un-pausing would have to take it back.
|
|
32646
|
+
*/
|
|
32647
|
+
audioRate = 1;
|
|
32648
|
+
audioListener = null;
|
|
31683
32649
|
audioDroppedSeek = 0;
|
|
31684
32650
|
firstAudioLogged = false;
|
|
31685
32651
|
audioReport = null;
|
|
31686
32652
|
audioWaiters = [];
|
|
32653
|
+
/** The provider's last word, and whether it has been given. Settled is not
|
|
32654
|
+
* the same as non-null: a whole stream settles `null` (D623). */
|
|
32655
|
+
incompleteSettled = false;
|
|
32656
|
+
incompleteReport = null;
|
|
32657
|
+
incompleteWaiters = [];
|
|
31687
32658
|
clockHasAdvanced = false;
|
|
31688
32659
|
rate = 1;
|
|
31689
32660
|
queue = [];
|
|
@@ -31715,12 +32686,16 @@ var ClipStreamFeeder = class {
|
|
|
31715
32686
|
firstFrameLogged = false;
|
|
31716
32687
|
maxBufferedBytes;
|
|
31717
32688
|
stallTimeoutMs;
|
|
32689
|
+
prerollMs;
|
|
32690
|
+
/** True while the pacer is waiting for the cushion to refill after a starve. */
|
|
32691
|
+
rebuffering = false;
|
|
31718
32692
|
now;
|
|
31719
32693
|
tags;
|
|
31720
32694
|
constructor(deps) {
|
|
31721
32695
|
this.deps = deps;
|
|
31722
32696
|
this.maxBufferedBytes = deps.maxBufferedBytes ?? CLIP_STREAM_BUFFER_MAX_BYTES;
|
|
31723
32697
|
this.stallTimeoutMs = deps.stallTimeoutMs ?? 15e3;
|
|
32698
|
+
this.prerollMs = deps.prerollMs ?? 600;
|
|
31724
32699
|
this.now = deps.now ?? (() => Date.now());
|
|
31725
32700
|
this.tags = {
|
|
31726
32701
|
deviceId: deps.handle.deviceId,
|
|
@@ -31818,6 +32793,8 @@ var ClipStreamFeeder = class {
|
|
|
31818
32793
|
this.clock.setRate(next);
|
|
31819
32794
|
const wasPaused = this.rate === 0;
|
|
31820
32795
|
this.rate = next;
|
|
32796
|
+
this.audioRate = next;
|
|
32797
|
+
this.refreshAudioReport();
|
|
31821
32798
|
if (this.phase !== "streaming" || this.pendingSeekMs !== null) return;
|
|
31822
32799
|
if (wasPaused) this.emitState("playing", "set-rate-resume", this.seekToken);
|
|
31823
32800
|
this.startPacing();
|
|
@@ -31852,6 +32829,19 @@ var ClipStreamFeeder = class {
|
|
|
31852
32829
|
return new Promise((resolve) => this.durationWaiters.push(resolve));
|
|
31853
32830
|
}
|
|
31854
32831
|
/**
|
|
32832
|
+
* Did the provider deliver this clip whole? (D623)
|
|
32833
|
+
*
|
|
32834
|
+
* Resolves `null` for a whole stream AND for one that never got to say —
|
|
32835
|
+
* cut, disposed, or opened against a provider that adds no trailer. Both are
|
|
32836
|
+
* "nothing to tell the operator", and neither may be reported as a defect:
|
|
32837
|
+
* a cut already announces itself as `state: error`, and inventing a second
|
|
32838
|
+
* complaint for it would file one fault twice.
|
|
32839
|
+
*/
|
|
32840
|
+
incomplete() {
|
|
32841
|
+
if (this.incompleteSettled) return Promise.resolve(this.incompleteReport);
|
|
32842
|
+
return new Promise((resolve) => this.incompleteWaiters.push(resolve));
|
|
32843
|
+
}
|
|
32844
|
+
/**
|
|
31855
32845
|
* What this clip turned out to carry as sound — the answer the viewer needs
|
|
31856
32846
|
* to say "this clip has no audio" rather than leave the operator wondering.
|
|
31857
32847
|
*
|
|
@@ -31861,15 +32851,31 @@ var ClipStreamFeeder = class {
|
|
|
31861
32851
|
*/
|
|
31862
32852
|
audio() {
|
|
31863
32853
|
const settled = this.audioReport;
|
|
31864
|
-
if (settled !== null) return Promise.resolve(settled);
|
|
32854
|
+
if (settled !== null) return Promise.resolve(this.audioNow(settled));
|
|
31865
32855
|
return new Promise((resolve) => this.audioWaiters.push(resolve));
|
|
31866
32856
|
}
|
|
32857
|
+
/**
|
|
32858
|
+
* Follow the audio answer for as long as this clip is on the pointer.
|
|
32859
|
+
*
|
|
32860
|
+
* {@link audio} settles ONCE, because what the stream carries is decided
|
|
32861
|
+
* once — at the `moov`. The RATE is not: it moves under the operator's
|
|
32862
|
+
* finger, and a one-shot report cannot say that the clip which had sound a
|
|
32863
|
+
* second ago is silent now because he pressed 2×. The listener is told only
|
|
32864
|
+
* when the answer really changes, never the settle it already awaited.
|
|
32865
|
+
*
|
|
32866
|
+
* One listener, replaced rather than added to: there is one session behind
|
|
32867
|
+
* one clip, and a list would outlive the pointer.
|
|
32868
|
+
*/
|
|
32869
|
+
watchAudio(listener) {
|
|
32870
|
+
this.audioListener = listener;
|
|
32871
|
+
}
|
|
31867
32872
|
dispose() {
|
|
31868
32873
|
if (this.phase === "disposed") return;
|
|
31869
32874
|
const before = this.phase;
|
|
31870
32875
|
this.phase = "disposed";
|
|
31871
32876
|
this.teardown();
|
|
31872
32877
|
this.settleDuration(null);
|
|
32878
|
+
this.settleIncomplete(null);
|
|
31873
32879
|
this.settleAudio({
|
|
31874
32880
|
carried: false,
|
|
31875
32881
|
codec: null,
|
|
@@ -31940,13 +32946,15 @@ var ClipStreamFeeder = class {
|
|
|
31940
32946
|
audioReason: answer.audioReason ?? null
|
|
31941
32947
|
});
|
|
31942
32948
|
}
|
|
32949
|
+
const abandon = new AbortController();
|
|
32950
|
+
this.opening = abandon;
|
|
31943
32951
|
let opened;
|
|
31944
32952
|
try {
|
|
31945
32953
|
opened = await this.deps.open(answer.url, {
|
|
31946
32954
|
onData: (chunk) => this.onData(chunk),
|
|
31947
|
-
onEnd: () => this.onEnd(),
|
|
32955
|
+
onEnd: (report) => this.onEnd(report),
|
|
31948
32956
|
onError: (err) => this.cut("stream-failed", err.message)
|
|
31949
|
-
});
|
|
32957
|
+
}, abandon.signal);
|
|
31950
32958
|
} catch (err) {
|
|
31951
32959
|
opened = {
|
|
31952
32960
|
kind: "refused",
|
|
@@ -31955,8 +32963,15 @@ var ClipStreamFeeder = class {
|
|
|
31955
32963
|
detail: describe(err)
|
|
31956
32964
|
};
|
|
31957
32965
|
}
|
|
32966
|
+
if (this.opening === abandon) this.opening = null;
|
|
31958
32967
|
if (this.phase !== "opening") {
|
|
31959
32968
|
if (opened.kind === "open") opened.connection.destroy();
|
|
32969
|
+
this.log("clip-stream:open-abandoned", {
|
|
32970
|
+
phase: this.phase,
|
|
32971
|
+
opened: opened.kind,
|
|
32972
|
+
sinceDialMs: this.now() - (this.dialedAt ?? this.now()),
|
|
32973
|
+
...opened.kind === "refused" ? { code: opened.code } : {}
|
|
32974
|
+
});
|
|
31960
32975
|
return;
|
|
31961
32976
|
}
|
|
31962
32977
|
if (opened.kind === "refused") {
|
|
@@ -31976,6 +32991,7 @@ var ClipStreamFeeder = class {
|
|
|
31976
32991
|
this.phase = "refused";
|
|
31977
32992
|
this.teardown();
|
|
31978
32993
|
this.settleDuration(null);
|
|
32994
|
+
this.settleIncomplete(null);
|
|
31979
32995
|
this.settleAudio({
|
|
31980
32996
|
carried: false,
|
|
31981
32997
|
codec: null,
|
|
@@ -32097,10 +33113,24 @@ var ClipStreamFeeder = class {
|
|
|
32097
33113
|
return;
|
|
32098
33114
|
}
|
|
32099
33115
|
while (this.rate === 0 && this.pendingSteps > 0 && this.pushNext()) this.pendingSteps -= 1;
|
|
32100
|
-
if (this.rate
|
|
33116
|
+
if (this.rate === 0) return;
|
|
33117
|
+
if (this.rebuffering) {
|
|
33118
|
+
if (this.cushionShortfallMs() > 0) return;
|
|
33119
|
+
this.rebuffering = false;
|
|
33120
|
+
this.log("clip-stream:refilled", {
|
|
33121
|
+
headMs: this.headMs,
|
|
33122
|
+
arrivedMs: this.arrivedMs,
|
|
33123
|
+
queued: this.queue.length,
|
|
33124
|
+
starved: this.starved
|
|
33125
|
+
});
|
|
33126
|
+
this.startPacing();
|
|
33127
|
+
return;
|
|
33128
|
+
}
|
|
33129
|
+
if (this.paceTimer === null) this.tick();
|
|
32101
33130
|
}
|
|
32102
|
-
onEnd() {
|
|
33131
|
+
onEnd(report) {
|
|
32103
33132
|
if (!this.takesBytes()) return;
|
|
33133
|
+
this.settleIncomplete(report);
|
|
32104
33134
|
this.streamEnded = true;
|
|
32105
33135
|
this.clearStall();
|
|
32106
33136
|
this.streamEnd = this.demuxer.end();
|
|
@@ -32118,13 +33148,20 @@ var ClipStreamFeeder = class {
|
|
|
32118
33148
|
this.resolvePendingSeek(this.seekToken);
|
|
32119
33149
|
return;
|
|
32120
33150
|
}
|
|
32121
|
-
if (this.rate
|
|
33151
|
+
if (this.rate === 0) return;
|
|
33152
|
+
if (this.rebuffering || this.anchorWall > this.now()) {
|
|
33153
|
+
this.rebuffering = false;
|
|
33154
|
+
this.startPacing();
|
|
33155
|
+
return;
|
|
33156
|
+
}
|
|
33157
|
+
if (this.paceTimer === null) this.tick();
|
|
32122
33158
|
}
|
|
32123
33159
|
cut(reason, detail) {
|
|
32124
33160
|
if (this.phase !== "streaming" && this.phase !== "opening") return;
|
|
32125
33161
|
this.phase = "cut";
|
|
32126
33162
|
this.teardown();
|
|
32127
33163
|
this.settleDuration(null);
|
|
33164
|
+
this.settleIncomplete(null);
|
|
32128
33165
|
this.settleAudio({
|
|
32129
33166
|
carried: false,
|
|
32130
33167
|
codec: null,
|
|
@@ -32258,9 +33295,24 @@ var ClipStreamFeeder = class {
|
|
|
32258
33295
|
this.audioQueue = this.audioQueue.slice(drop);
|
|
32259
33296
|
this.audioDroppedSeek += drop;
|
|
32260
33297
|
}
|
|
33298
|
+
/**
|
|
33299
|
+
* How much of the cushion is MISSING, in milliseconds of media.
|
|
33300
|
+
*
|
|
33301
|
+
* Zero once the stream has ended — there is nothing more coming, so waiting
|
|
33302
|
+
* for it would hang the clip — and zero for any source that already holds
|
|
33303
|
+
* more than the cushion, which is every fast one.
|
|
33304
|
+
*/
|
|
33305
|
+
cushionShortfallMs() {
|
|
33306
|
+
if (this.streamEnded) return 0;
|
|
33307
|
+
const head = this.headMs;
|
|
33308
|
+
const arrived = this.arrivedMs;
|
|
33309
|
+
if (head === null || arrived === null) return this.prerollMs;
|
|
33310
|
+
return Math.max(0, this.prerollMs - (arrived - head));
|
|
33311
|
+
}
|
|
32261
33312
|
startPacing() {
|
|
32262
33313
|
this.stopPacing();
|
|
32263
|
-
|
|
33314
|
+
const shortfall = this.cushionShortfallMs();
|
|
33315
|
+
this.anchorWall = this.now() + shortfall;
|
|
32264
33316
|
this.anchorPts = this.headMs ?? this.queue[0]?.ptsMs ?? 0;
|
|
32265
33317
|
this.startPositionReports();
|
|
32266
33318
|
this.tick();
|
|
@@ -32285,7 +33337,10 @@ var ClipStreamFeeder = class {
|
|
|
32285
33337
|
const audio = this.audioQueue[0];
|
|
32286
33338
|
if (video === void 0 && audio === void 0) {
|
|
32287
33339
|
if (this.streamEnded) this.finish();
|
|
32288
|
-
else
|
|
33340
|
+
else {
|
|
33341
|
+
this.rebuffering = true;
|
|
33342
|
+
this.noteStarved();
|
|
33343
|
+
}
|
|
32289
33344
|
return;
|
|
32290
33345
|
}
|
|
32291
33346
|
const takeVideo = video !== void 0 && (audio === void 0 || video.ptsMs <= audio.ptsMs);
|
|
@@ -32321,7 +33376,7 @@ var ClipStreamFeeder = class {
|
|
|
32321
33376
|
if (frame === void 0) return false;
|
|
32322
33377
|
this.audioQueue = this.audioQueue.slice(1);
|
|
32323
33378
|
this.audioQueuedBytes -= frame.data.byteLength;
|
|
32324
|
-
if (this.rate
|
|
33379
|
+
if (!audioPlaysAtRate(this.rate)) {
|
|
32325
33380
|
this.audioDroppedRate += 1;
|
|
32326
33381
|
return true;
|
|
32327
33382
|
}
|
|
@@ -32419,11 +33474,53 @@ var ClipStreamFeeder = class {
|
|
|
32419
33474
|
audioQueued: this.audioQueue.length
|
|
32420
33475
|
};
|
|
32421
33476
|
}
|
|
33477
|
+
/** One-shot, like the duration beside it: the provider speaks once, at the
|
|
33478
|
+
* end of the body, and a cut settles `null` so nobody waits for ever. */
|
|
33479
|
+
settleIncomplete(report) {
|
|
33480
|
+
if (this.incompleteSettled) return;
|
|
33481
|
+
this.incompleteSettled = true;
|
|
33482
|
+
this.incompleteReport = report;
|
|
33483
|
+
if (report !== null) this.log("clip-stream:incomplete", {
|
|
33484
|
+
code: report.code,
|
|
33485
|
+
reason: report.reason
|
|
33486
|
+
});
|
|
33487
|
+
for (const resolve of this.incompleteWaiters.splice(0)) resolve(report);
|
|
33488
|
+
}
|
|
32422
33489
|
settleAudio(report) {
|
|
32423
33490
|
if (this.audioReport !== null) return;
|
|
32424
33491
|
this.audioReport = report;
|
|
33492
|
+
const now = this.audioNow(report);
|
|
33493
|
+
this.audioEmitted = now;
|
|
32425
33494
|
const waiters = this.audioWaiters.splice(0);
|
|
32426
|
-
for (const resolve of waiters) resolve(
|
|
33495
|
+
for (const resolve of waiters) resolve(now);
|
|
33496
|
+
}
|
|
33497
|
+
/**
|
|
33498
|
+
* The audio answer AS IT STANDS — the stream's own settled fact, with the
|
|
33499
|
+
* rate laid over it.
|
|
33500
|
+
*
|
|
33501
|
+
* The overlay only ever applies to a clip that really is carrying sound. A
|
|
33502
|
+
* clip whose camera has no microphone is silent at 1× too, and replacing
|
|
33503
|
+
* `no-audio-track` with `rate` would swap the true cause for a transient
|
|
33504
|
+
* one and send the operator to the speed chip.
|
|
33505
|
+
*/
|
|
33506
|
+
audioNow(settled) {
|
|
33507
|
+
if (!settled.carried || audioPlaysAtRate(this.audioRate)) return settled;
|
|
33508
|
+
return {
|
|
33509
|
+
carried: false,
|
|
33510
|
+
codec: null,
|
|
33511
|
+
reason: "rate",
|
|
33512
|
+
rate: this.audioRate
|
|
33513
|
+
};
|
|
33514
|
+
}
|
|
33515
|
+
/** Tell the listener when — and only when — the answer has actually moved. */
|
|
33516
|
+
refreshAudioReport() {
|
|
33517
|
+
const settled = this.audioReport;
|
|
33518
|
+
if (settled === null) return;
|
|
33519
|
+
const next = this.audioNow(settled);
|
|
33520
|
+
const last = this.audioEmitted;
|
|
33521
|
+
if (last !== null && last.reason === next.reason && last.rate === next.rate) return;
|
|
33522
|
+
this.audioEmitted = next;
|
|
33523
|
+
this.audioListener?.(next);
|
|
32427
33524
|
}
|
|
32428
33525
|
settleDuration(value) {
|
|
32429
33526
|
if (this.duration !== null) return;
|
|
@@ -32438,6 +33535,9 @@ var ClipStreamFeeder = class {
|
|
|
32438
33535
|
this.stopPacing();
|
|
32439
33536
|
this.stopPositionReports();
|
|
32440
33537
|
this.clearStall();
|
|
33538
|
+
const opening = this.opening;
|
|
33539
|
+
this.opening = null;
|
|
33540
|
+
opening?.abort(/* @__PURE__ */ new Error(CLIP_STREAM_ABANDONED));
|
|
32441
33541
|
const connection = this.connection;
|
|
32442
33542
|
this.connection = null;
|
|
32443
33543
|
connection?.destroy();
|
|
@@ -32479,6 +33579,62 @@ var ClipStreamFeeder = class {
|
|
|
32479
33579
|
function describe(err) {
|
|
32480
33580
|
return err instanceof Error ? err.message : String(err);
|
|
32481
33581
|
}
|
|
33582
|
+
//#endregion
|
|
33583
|
+
//#region src/stream-broker/recorded/clip-stream-http.ts
|
|
33584
|
+
/**
|
|
33585
|
+
* clip-stream-http — the `node:http` half of {@link ClipStreamOpener}.
|
|
33586
|
+
*
|
|
33587
|
+
* Dials the URL `videoclips.dialClipStream` handed back — plain HTTP on the
|
|
33588
|
+
* provider's own token-gated listener, never the hub's proxy (an addon holds
|
|
33589
|
+
* no user credential, and the hub proxy takes only those) — and turns the
|
|
33590
|
+
* response into what the feeder drives: `pause`/`resume`/`destroy` on the
|
|
33591
|
+
* socket and the three events.
|
|
33592
|
+
*
|
|
33593
|
+
* A status other than 200 is a REFUSAL, named by the provider's own headers
|
|
33594
|
+
* (`x-camstack-reason-code`, `x-camstack-reason`), never a stream of nothing.
|
|
33595
|
+
* A 200 that does not declare `x-camstack-clip-transport: forward-only` is
|
|
33596
|
+
* refused too: it means the URL is not the stream route, and playing a
|
|
33597
|
+
* progressive file through a forward-only feeder would look like it worked
|
|
33598
|
+
* until the first seek.
|
|
33599
|
+
*/
|
|
33600
|
+
/**
|
|
33601
|
+
* The codes a provider may add AFTER its body, and the only two this hop
|
|
33602
|
+
* carries onward.
|
|
33603
|
+
*
|
|
33604
|
+
* A stream's status line is spent on its first fragment, so a serve the camera
|
|
33605
|
+
* cut short has nothing left to say it with but a trailer — and a trailer this
|
|
33606
|
+
* reader did not know about would be read as a whole clip, which is the defect
|
|
33607
|
+
* D623 is about, one hop further out. Anything else a provider writes there is
|
|
33608
|
+
* ITS diagnostic and stays in ITS log: a viewer is shown what it can act on.
|
|
33609
|
+
*/
|
|
33610
|
+
var CARRIED_TRAILER_CODES = ["stream-short", "stream-empty"];
|
|
33611
|
+
function trailerText(trailers, name) {
|
|
33612
|
+
const value = trailers[name];
|
|
33613
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
33614
|
+
}
|
|
33615
|
+
/**
|
|
33616
|
+
* What the provider said about its own stream once the last byte was out.
|
|
33617
|
+
*
|
|
33618
|
+
* `null` for a stream that added nothing, which is a whole one — the absence
|
|
33619
|
+
* is the statement, and it is never read as "unknown". A code with no sentence
|
|
33620
|
+
* is still reported, with the code standing in for one: a consumer that
|
|
33621
|
+
* branches on the code must not lose the branch because the prose was dropped
|
|
33622
|
+
* by a proxy.
|
|
33623
|
+
*/
|
|
33624
|
+
function readClipStreamTrailers(trailers) {
|
|
33625
|
+
const code = trailerText(trailers, CLIP_STILL_REASON_CODE_HEADER);
|
|
33626
|
+
if (code === void 0 || !CARRIED_TRAILER_CODES.includes(code)) return null;
|
|
33627
|
+
return {
|
|
33628
|
+
code: code === "stream-empty" ? "stream-empty" : "stream-short",
|
|
33629
|
+
reason: trailerText(trailers, "x-camstack-reason") ?? code
|
|
33630
|
+
};
|
|
33631
|
+
}
|
|
33632
|
+
/** The abort's reason, as a sentence the provider's log can be matched against. */
|
|
33633
|
+
function describeReason(reason) {
|
|
33634
|
+
if (reason instanceof Error) return reason.message;
|
|
33635
|
+
if (typeof reason === "string") return reason;
|
|
33636
|
+
return "the clip stream was abandoned before it opened";
|
|
33637
|
+
}
|
|
32482
33638
|
function headerText(res, name) {
|
|
32483
33639
|
const value = res.headers[name];
|
|
32484
33640
|
if (Array.isArray(value)) return value[0];
|
|
@@ -32486,14 +33642,26 @@ function headerText(res, name) {
|
|
|
32486
33642
|
}
|
|
32487
33643
|
function createHttpClipStreamOpener(options = {}) {
|
|
32488
33644
|
const headersTimeoutMs = options.headersTimeoutMs ?? 3e4;
|
|
32489
|
-
return (url, handlers) => new Promise((resolve) => {
|
|
33645
|
+
return (url, handlers, signal) => new Promise((resolve) => {
|
|
32490
33646
|
let settled = false;
|
|
33647
|
+
let onAbort = null;
|
|
32491
33648
|
const settle = (value) => {
|
|
32492
33649
|
if (settled) return;
|
|
32493
33650
|
settled = true;
|
|
32494
33651
|
clearTimeout(timer);
|
|
33652
|
+
if (onAbort !== null) signal.removeEventListener("abort", onAbort);
|
|
32495
33653
|
resolve(value);
|
|
32496
33654
|
};
|
|
33655
|
+
const abandoned = () => ({
|
|
33656
|
+
kind: "refused",
|
|
33657
|
+
status: 0,
|
|
33658
|
+
code: "abandoned",
|
|
33659
|
+
detail: describeReason(signal.reason)
|
|
33660
|
+
});
|
|
33661
|
+
if (signal.aborted) {
|
|
33662
|
+
resolve(abandoned());
|
|
33663
|
+
return;
|
|
33664
|
+
}
|
|
32497
33665
|
let target;
|
|
32498
33666
|
try {
|
|
32499
33667
|
target = new URL(url);
|
|
@@ -32522,7 +33690,16 @@ function createHttpClipStreamOpener(options = {}) {
|
|
|
32522
33690
|
const timer = setTimeout(() => {
|
|
32523
33691
|
req.destroy(/* @__PURE__ */ new Error(`no status line in ${headersTimeoutMs} ms`));
|
|
32524
33692
|
}, headersTimeoutMs);
|
|
33693
|
+
onAbort = () => {
|
|
33694
|
+
req.destroy(new Error(describeReason(signal.reason)));
|
|
33695
|
+
settle(abandoned());
|
|
33696
|
+
};
|
|
33697
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
32525
33698
|
req.on("error", (err) => {
|
|
33699
|
+
if (signal.aborted) {
|
|
33700
|
+
settle(abandoned());
|
|
33701
|
+
return;
|
|
33702
|
+
}
|
|
32526
33703
|
settle({
|
|
32527
33704
|
kind: "refused",
|
|
32528
33705
|
status: 0,
|
|
@@ -32553,7 +33730,7 @@ function createHttpClipStreamOpener(options = {}) {
|
|
|
32553
33730
|
return;
|
|
32554
33731
|
}
|
|
32555
33732
|
res.on("data", (chunk) => handlers.onData(chunk));
|
|
32556
|
-
res.on("end", () => handlers.onEnd());
|
|
33733
|
+
res.on("end", () => handlers.onEnd(readClipStreamTrailers(res.trailers)));
|
|
32557
33734
|
res.on("error", (err) => handlers.onError(err));
|
|
32558
33735
|
settle({
|
|
32559
33736
|
kind: "open",
|
|
@@ -33143,24 +34320,13 @@ function pickAdaptiveTier(input) {
|
|
|
33143
34320
|
*
|
|
33144
34321
|
* Flow: Camera → RTSP → Broker → EncodedPacket → RTP packetization → WebRTC
|
|
33145
34322
|
*/
|
|
33146
|
-
/**
|
|
33147
|
-
|
|
33148
|
-
|
|
33149
|
-
|
|
33150
|
-
|
|
33151
|
-
|
|
33152
|
-
|
|
33153
|
-
upgradeHealthyMs: 4e3,
|
|
33154
|
-
upgradeHealthyGrowthFactor: 3,
|
|
33155
|
-
maxProgressiveUpgradeHealthyMs: 3e4,
|
|
33156
|
-
minTimeBetweenChangesMs: 4e3,
|
|
33157
|
-
maxUpgradeBackoffMs: 9e5,
|
|
33158
|
-
stallKbpsFloor: 64,
|
|
33159
|
-
stallConfirmMs: 6e3,
|
|
33160
|
-
recoverKbpsFloor: 128,
|
|
33161
|
-
waitAfterChangeMs: 8e3,
|
|
33162
|
-
maxClientSampleAgeMs: 1e4
|
|
33163
|
-
};
|
|
34323
|
+
/**
|
|
34324
|
+
* A hold code that has been logged stays quiet this long before it may log
|
|
34325
|
+
* again (`HoldLogGate`). 60 000 ms: a hovering link changes code every 4–8 s.
|
|
34326
|
+
*/
|
|
34327
|
+
var HOLD_LOG_QUIET_MS = 6e4;
|
|
34328
|
+
/** A failing adaptive tick logs at most once per this long, per session. */
|
|
34329
|
+
var TICK_FAILURE_LOG_QUIET_MS = 6e4;
|
|
33164
34330
|
/**
|
|
33165
34331
|
* True when `broker` is an in-process StreamBroker exposing the Opus egress
|
|
33166
34332
|
* controls. Uses `in` narrowing + `typeof` checks (no cast) so the returned
|
|
@@ -33397,6 +34563,14 @@ var BrokerWebrtcServer = class {
|
|
|
33397
34563
|
*/
|
|
33398
34564
|
lastLadderDecision = /* @__PURE__ */ new Map();
|
|
33399
34565
|
/**
|
|
34566
|
+
* Try-climb memory per DEVICE (review I4): a new viewer session of a camera
|
|
34567
|
+
* whose next rung keeps failing inherits the backoff instead of starting at
|
|
34568
|
+
* try #1. Forgotten after the backoff cap without a refresh.
|
|
34569
|
+
*/
|
|
34570
|
+
deviceTryMemory = new DeviceTryMemory(ADAPTIVE_CONFIG.tryBackoffMaxMs);
|
|
34571
|
+
/** The newest viewer probe per device, from offers and telemetry (`probe-evidence.ts`). */
|
|
34572
|
+
deviceProbe = new DeviceProbeMemory(PROBE_MEMORY_TTL_MS);
|
|
34573
|
+
/**
|
|
33400
34574
|
* brokerId → where its media is ingested. Injected by the manager, which
|
|
33401
34575
|
* owns the ownership map; unset (or a null answer) means UNRESOLVED, and
|
|
33402
34576
|
* the overlay says so rather than naming the local node.
|
|
@@ -34394,7 +35568,8 @@ var BrokerWebrtcServer = class {
|
|
|
34394
35568
|
};
|
|
34395
35569
|
if (streamId.endsWith("/adaptive")) {
|
|
34396
35570
|
const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
|
|
34397
|
-
this.
|
|
35571
|
+
this.recordDeviceProbe(deviceId, probeFromHints(hints.downlinkMbps, Date.now()));
|
|
35572
|
+
this.startAdaptive(entry, deviceId, initialTier, sessionId, intent);
|
|
34398
35573
|
}
|
|
34399
35574
|
return {
|
|
34400
35575
|
sessionId,
|
|
@@ -34512,11 +35687,13 @@ var BrokerWebrtcServer = class {
|
|
|
34512
35687
|
const ladder = controller !== void 0 ? {
|
|
34513
35688
|
lastDecision: decision,
|
|
34514
35689
|
failures: [...controller.failureCounts],
|
|
34515
|
-
upgradeCount: controller.successfulUpgradeCount
|
|
35690
|
+
upgradeCount: controller.successfulUpgradeCount,
|
|
35691
|
+
climbHold: controller.climbHold === null ? null : toWireClimbHold(controller.climbHold)
|
|
34516
35692
|
} : decision !== null ? {
|
|
34517
35693
|
lastDecision: decision,
|
|
34518
35694
|
failures: [],
|
|
34519
|
-
upgradeCount: 0
|
|
35695
|
+
upgradeCount: 0,
|
|
35696
|
+
climbHold: null
|
|
34520
35697
|
} : null;
|
|
34521
35698
|
let brokerStats = null;
|
|
34522
35699
|
try {
|
|
@@ -34559,7 +35736,7 @@ var BrokerWebrtcServer = class {
|
|
|
34559
35736
|
/** Snapshot the ladder's move so the overlay can name it later. */
|
|
34560
35737
|
recordLadderDecision(sessionId, decision, tier, forcedTranscodeDown) {
|
|
34561
35738
|
this.lastLadderDecision.set(sessionId, {
|
|
34562
|
-
|
|
35739
|
+
...wireLadderCause(decision, ADAPTIVE_CONFIG.stallConfirmMs),
|
|
34563
35740
|
tier,
|
|
34564
35741
|
forcedTranscodeDown,
|
|
34565
35742
|
lossRatio: decision.lossRatio,
|
|
@@ -34570,6 +35747,39 @@ var BrokerWebrtcServer = class {
|
|
|
34570
35747
|
});
|
|
34571
35748
|
}
|
|
34572
35749
|
/**
|
|
35750
|
+
* The IDR interval the broker feeding a session measured, or null when it
|
|
35751
|
+
* has measured none (a fresh broker reports 0). Sizes the delivery window.
|
|
35752
|
+
*/
|
|
35753
|
+
brokerIdrIntervalMs(brokerId) {
|
|
35754
|
+
try {
|
|
35755
|
+
const idr = this.brokers.get(brokerId)?.getStats().idrIntervalMs ?? null;
|
|
35756
|
+
return idr !== null && idr > 0 ? idr : null;
|
|
35757
|
+
} catch {
|
|
35758
|
+
return null;
|
|
35759
|
+
}
|
|
35760
|
+
}
|
|
35761
|
+
/**
|
|
35762
|
+
* The expected bitrate of `tier` for this device, from the broker serving
|
|
35763
|
+
* that rung — null when no such broker is registered here or it has
|
|
35764
|
+
* measured nothing. Unknown never vetoes a climb (D315/D393).
|
|
35765
|
+
*/
|
|
35766
|
+
expectedTierKbps(deviceId, tier) {
|
|
35767
|
+
const prefix = `${String(deviceId)}/`;
|
|
35768
|
+
const candidates = [];
|
|
35769
|
+
for (const [bid, broker] of this.brokers) if (bid.startsWith(prefix)) candidates.push({
|
|
35770
|
+
brokerId: bid,
|
|
35771
|
+
tier: this.profileTierResolver?.(bid) ?? null,
|
|
35772
|
+
broker
|
|
35773
|
+
});
|
|
35774
|
+
const match = preferLiveAdaptiveCandidates(candidates).find((c) => c.tier === tier);
|
|
35775
|
+
if (match === void 0) return null;
|
|
35776
|
+
try {
|
|
35777
|
+
return expectedRungKbps(match.broker.getStats());
|
|
35778
|
+
} catch {
|
|
35779
|
+
return null;
|
|
35780
|
+
}
|
|
35781
|
+
}
|
|
35782
|
+
/**
|
|
34573
35783
|
* Wire an {@link AdaptiveController} to a freshly-created adaptive session.
|
|
34574
35784
|
* The controller is fed loss from two sources — RTCP Receiver Reports
|
|
34575
35785
|
* (`onReceiverReport`) and, when injected, the device's client-reported
|
|
@@ -34586,12 +35796,20 @@ var BrokerWebrtcServer = class {
|
|
|
34586
35796
|
* whichever produces the higher loss ratio across its window. The async
|
|
34587
35797
|
* poll is guarded against overlapping ticks via `tickInFlight`.
|
|
34588
35798
|
*/
|
|
34589
|
-
startAdaptive(entry, deviceId, initialTier, sessionId,
|
|
35799
|
+
startAdaptive(entry, deviceId, initialTier, sessionId, intent) {
|
|
35800
|
+
const startedAt = Date.now();
|
|
35801
|
+
/** Rung bitrates, looked up at most once per tier per tick (review m12). */
|
|
35802
|
+
const rungKbps = /* @__PURE__ */ new Map();
|
|
34590
35803
|
const controller = new AdaptiveController({
|
|
34591
35804
|
initialTier,
|
|
34592
|
-
priorFailures,
|
|
34593
|
-
priorUpgradeCount,
|
|
35805
|
+
priorFailures: intent?.failures,
|
|
35806
|
+
priorUpgradeCount: intent?.upgradeCount,
|
|
35807
|
+
tryClimb: intent?.tryClimb ?? this.deviceTryMemory.get(deviceId, startedAt) ?? void 0,
|
|
34594
35808
|
config: ADAPTIVE_CONFIG,
|
|
35809
|
+
expectedTierKbps: (tier) => {
|
|
35810
|
+
if (!rungKbps.has(tier)) rungKbps.set(tier, this.expectedTierKbps(deviceId, tier));
|
|
35811
|
+
return rungKbps.get(tier) ?? null;
|
|
35812
|
+
},
|
|
34595
35813
|
onSwitchTier: (tier, decision) => {
|
|
34596
35814
|
this.recordLadderDecision(sessionId, decision, tier, false);
|
|
34597
35815
|
this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
|
|
@@ -34608,6 +35826,9 @@ var BrokerWebrtcServer = class {
|
|
|
34608
35826
|
reason: decision.cause,
|
|
34609
35827
|
lossRatio: Number(decision.lossRatio.toFixed(4)),
|
|
34610
35828
|
clientKbps: decision.stall.clientKbps,
|
|
35829
|
+
sentKbps: decision.stall.sentKbps,
|
|
35830
|
+
deliveryRatio: decision.stall.deliveryRatio,
|
|
35831
|
+
deliveryWindowMs: decision.stall.deliveryWindowMs,
|
|
34611
35832
|
stalledForMs: decision.stall.stalledForMs,
|
|
34612
35833
|
serverSending: decision.stall.serverSending
|
|
34613
35834
|
}
|
|
@@ -34627,14 +35848,39 @@ var BrokerWebrtcServer = class {
|
|
|
34627
35848
|
* sending?" half of the stall verdict. */
|
|
34628
35849
|
let lastRtpSent = entry.session.rtpPacketsSentTotal;
|
|
34629
35850
|
const CLIENT_LOSS_SPAN = 1e3;
|
|
35851
|
+
const meter = new DeliveryMeter(ADAPTIVE_DELIVERY_CONFIG, deliveryMeterStart(startedAt, intent !== void 0, ADAPTIVE_CONFIG.waitAfterChangeMs));
|
|
35852
|
+
const holdLog = new HoldLogGate(HOLD_LOG_QUIET_MS);
|
|
35853
|
+
let lastTickFailureLogAt = Number.NEGATIVE_INFINITY;
|
|
35854
|
+
const logHoldChange = (now) => {
|
|
35855
|
+
const hold = controller.climbHold;
|
|
35856
|
+
if (hold === null || !holdLog.shouldLog(hold.code, now)) return;
|
|
35857
|
+
this.logger.info("adaptive: climb held", {
|
|
35858
|
+
tags: { deviceId },
|
|
35859
|
+
meta: {
|
|
35860
|
+
sessionId,
|
|
35861
|
+
deviceId,
|
|
35862
|
+
tier: controller.currentTier,
|
|
35863
|
+
nextTier: hold.nextTier,
|
|
35864
|
+
code: hold.code,
|
|
35865
|
+
summary: describeClimbHold(hold),
|
|
35866
|
+
deliveryRatio: hold.deliveryRatio,
|
|
35867
|
+
deliveryWindowMs: hold.deliveryWindowMs,
|
|
35868
|
+
capacityKbps: hold.capacityKbps,
|
|
35869
|
+
probeKbps: hold.probeKbps,
|
|
35870
|
+
nextTierKbps: hold.nextTierKbps
|
|
35871
|
+
}
|
|
35872
|
+
});
|
|
35873
|
+
};
|
|
34630
35874
|
entry.adaptiveTimer = setInterval(() => {
|
|
34631
35875
|
if (tickInFlight) return;
|
|
34632
35876
|
tickInFlight = true;
|
|
34633
35877
|
(async () => {
|
|
34634
35878
|
try {
|
|
35879
|
+
rungKbps.clear();
|
|
34635
35880
|
if (entry.timeline?.currentMode === "recorded") {
|
|
34636
35881
|
controller.suspend(Date.now());
|
|
34637
35882
|
lastRtpSent = entry.session.rtpPacketsSentTotal;
|
|
35883
|
+
logHoldChange(Date.now());
|
|
34638
35884
|
return;
|
|
34639
35885
|
}
|
|
34640
35886
|
const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
|
|
@@ -34642,11 +35888,15 @@ var BrokerWebrtcServer = class {
|
|
|
34642
35888
|
const rtpSent = entry.session.rtpPacketsSentTotal;
|
|
34643
35889
|
const serverSending = rtpSent > lastRtpSent;
|
|
34644
35890
|
lastRtpSent = rtpSent;
|
|
34645
|
-
|
|
34646
|
-
|
|
34647
|
-
|
|
34648
|
-
|
|
34649
|
-
|
|
35891
|
+
this.feedAdaptive({
|
|
35892
|
+
entry,
|
|
35893
|
+
deviceId,
|
|
35894
|
+
controller,
|
|
35895
|
+
meter,
|
|
35896
|
+
client,
|
|
35897
|
+
serverSending,
|
|
35898
|
+
now
|
|
35899
|
+
});
|
|
34650
35900
|
const pct = client?.packetLossPercent ?? null;
|
|
34651
35901
|
if (pct !== null && pct !== void 0) {
|
|
34652
35902
|
clientHighestSequence += CLIENT_LOSS_SPAN;
|
|
@@ -34657,6 +35907,21 @@ var BrokerWebrtcServer = class {
|
|
|
34657
35907
|
}, now);
|
|
34658
35908
|
}
|
|
34659
35909
|
controller.tick(now);
|
|
35910
|
+
this.deviceTryMemory.set(deviceId, controller.tryClimbCarry, now);
|
|
35911
|
+
logHoldChange(now);
|
|
35912
|
+
} catch (err) {
|
|
35913
|
+
const now = Date.now();
|
|
35914
|
+
if (now - lastTickFailureLogAt >= TICK_FAILURE_LOG_QUIET_MS) {
|
|
35915
|
+
lastTickFailureLogAt = now;
|
|
35916
|
+
this.logger.warn("adaptive: tick failed", {
|
|
35917
|
+
tags: { deviceId },
|
|
35918
|
+
meta: {
|
|
35919
|
+
sessionId,
|
|
35920
|
+
deviceId,
|
|
35921
|
+
error: errMsg(err)
|
|
35922
|
+
}
|
|
35923
|
+
});
|
|
35924
|
+
}
|
|
34660
35925
|
} finally {
|
|
34661
35926
|
tickInFlight = false;
|
|
34662
35927
|
}
|
|
@@ -34664,6 +35929,55 @@ var BrokerWebrtcServer = class {
|
|
|
34664
35929
|
}, ADAPTIVE_TICK_MS);
|
|
34665
35930
|
}
|
|
34666
35931
|
/**
|
|
35932
|
+
* One evaluation's transport, capacity and RTT evidence into the controller.
|
|
35933
|
+
* The device-scoped client record is used as this session's own only when
|
|
35934
|
+
* this is the device's ONLY adaptive session here (review I2).
|
|
35935
|
+
*/
|
|
35936
|
+
feedAdaptive(feed) {
|
|
35937
|
+
const { entry, deviceId, controller, meter, client, now } = feed;
|
|
35938
|
+
const sessionsOnDevice = this.adaptiveSessionCount(deviceId);
|
|
35939
|
+
const shared = sessionsOnDevice > 1;
|
|
35940
|
+
let rttMs = null;
|
|
35941
|
+
if (client) {
|
|
35942
|
+
const reportAgeMs = Math.max(0, now - client.lastUpdated);
|
|
35943
|
+
meter.observe(client.estimatedBandwidthKbps, client.lastUpdated);
|
|
35944
|
+
if (!shared) {
|
|
35945
|
+
this.recordDeviceProbe(deviceId, probeFromReport({
|
|
35946
|
+
downlinkMbps: client.downlinkMbps,
|
|
35947
|
+
downlinkAgeMs: client.downlinkAgeMs ?? 0,
|
|
35948
|
+
reportAgeMs
|
|
35949
|
+
}, now));
|
|
35950
|
+
if (reportAgeMs <= ADAPTIVE_DELIVERY_CONFIG.maxClientSampleAgeMs) rttMs = client.rttMs ?? null;
|
|
35951
|
+
}
|
|
35952
|
+
}
|
|
35953
|
+
const history = entry.session.sentVideoHistory;
|
|
35954
|
+
controller.onThroughput(buildStallSample({
|
|
35955
|
+
delivery: meter.evaluate(history, deliveryWindowMs(this.brokerIdrIntervalMs(entry.brokerId), ADAPTIVE_DELIVERY_CONFIG), now),
|
|
35956
|
+
recent: meter.evaluateNewest(history, now),
|
|
35957
|
+
clientReporting: meter.isReporting(now),
|
|
35958
|
+
serverSending: feed.serverSending,
|
|
35959
|
+
adaptiveSessionsOnDevice: sessionsOnDevice
|
|
35960
|
+
}), now);
|
|
35961
|
+
const probe = this.deviceProbe.get(deviceId, now);
|
|
35962
|
+
controller.onCapacity({
|
|
35963
|
+
rembKbps: entry.session.receiverEstimateKbps(now),
|
|
35964
|
+
probeKbps: probe?.kbps ?? null,
|
|
35965
|
+
probeFresh: isProbeFresh(probe, now)
|
|
35966
|
+
});
|
|
35967
|
+
controller.onRtt(rttMs, now);
|
|
35968
|
+
}
|
|
35969
|
+
/** Adaptive sessions of `deviceId` currently on this broker server. */
|
|
35970
|
+
adaptiveSessionCount(deviceId) {
|
|
35971
|
+
const prefix = `${String(deviceId)}/`;
|
|
35972
|
+
let count = 0;
|
|
35973
|
+
for (const s of this.sessions.values()) if (s.adaptiveController !== void 0 && s.brokerId.startsWith(prefix)) count += 1;
|
|
35974
|
+
return count;
|
|
35975
|
+
}
|
|
35976
|
+
/** Keep the newest probe per device; an absent one changes nothing. */
|
|
35977
|
+
recordDeviceProbe(deviceId, probe) {
|
|
35978
|
+
this.deviceProbe.record(deviceId, probe);
|
|
35979
|
+
}
|
|
35980
|
+
/**
|
|
34667
35981
|
* Schedule an adaptive tier switch via a close-and-recreate re-offer.
|
|
34668
35982
|
*
|
|
34669
35983
|
* Records the controller's desired tier (+ forced transcode-down) in
|
|
@@ -34680,11 +35994,18 @@ var BrokerWebrtcServer = class {
|
|
|
34680
35994
|
const prev = this.adaptiveIntent.get(sessionId);
|
|
34681
35995
|
const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
|
|
34682
35996
|
const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
|
|
35997
|
+
const tryClimb = entry.adaptiveController?.tryClimbCarry ?? prev?.tryClimb;
|
|
34683
35998
|
this.adaptiveIntent.set(sessionId, {
|
|
34684
35999
|
prefersTier: tier,
|
|
34685
36000
|
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
|
|
34686
36001
|
failures,
|
|
34687
|
-
upgradeCount
|
|
36002
|
+
upgradeCount,
|
|
36003
|
+
tryClimb: tryClimb ?? {
|
|
36004
|
+
failures: 0,
|
|
36005
|
+
notBefore: 0,
|
|
36006
|
+
lastRevert: null,
|
|
36007
|
+
trialFrom: null
|
|
36008
|
+
}
|
|
34688
36009
|
});
|
|
34689
36010
|
const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
|
|
34690
36011
|
entry.session.pendingRenegotiation = {
|
|
@@ -34702,8 +36023,12 @@ var BrokerWebrtcServer = class {
|
|
|
34702
36023
|
failures: Object.fromEntries(failures),
|
|
34703
36024
|
upgradeCount,
|
|
34704
36025
|
reason: decision?.cause ?? "unknown",
|
|
36026
|
+
trial: decision?.trial ?? false,
|
|
34705
36027
|
lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
|
|
34706
36028
|
clientKbps: decision?.stall.clientKbps ?? null,
|
|
36029
|
+
sentKbps: decision?.stall.sentKbps ?? null,
|
|
36030
|
+
deliveryRatio: decision?.stall.deliveryRatio ?? null,
|
|
36031
|
+
deliveryWindowMs: decision?.stall.deliveryWindowMs ?? null,
|
|
34707
36032
|
stalledForMs: decision?.stall.stalledForMs ?? null,
|
|
34708
36033
|
serverSending: decision?.stall.serverSending ?? null
|
|
34709
36034
|
}
|
|
@@ -34720,6 +36045,10 @@ var BrokerWebrtcServer = class {
|
|
|
34720
36045
|
}
|
|
34721
36046
|
if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
|
|
34722
36047
|
this.adaptiveIntent.delete(sessionId);
|
|
36048
|
+
if (entry.adaptiveController !== void 0) {
|
|
36049
|
+
const deviceId = deviceIdFromBrokerId(entry.brokerId);
|
|
36050
|
+
if (this.adaptiveSessionCount(deviceId) === 0) this.deviceProbe.forget(deviceId);
|
|
36051
|
+
}
|
|
34723
36052
|
this.pruneLadderDecisions();
|
|
34724
36053
|
if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
|
|
34725
36054
|
if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
|
|
@@ -35880,7 +37209,10 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
35880
37209
|
return {
|
|
35881
37210
|
packetLossPercent: client.packetLossPercent,
|
|
35882
37211
|
estimatedBandwidthKbps: client.estimatedBandwidthKbps,
|
|
35883
|
-
lastUpdated: client.lastUpdated
|
|
37212
|
+
lastUpdated: client.lastUpdated,
|
|
37213
|
+
rttMs: client.rttMs,
|
|
37214
|
+
...client.downlinkMbps !== void 0 ? { downlinkMbps: client.downlinkMbps } : {},
|
|
37215
|
+
...client.downlinkAgeMs !== void 0 ? { downlinkAgeMs: client.downlinkAgeMs } : {}
|
|
35884
37216
|
};
|
|
35885
37217
|
} catch {
|
|
35886
37218
|
return null;
|
|
@@ -35888,8 +37220,8 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
35888
37220
|
clearTimeout(timer);
|
|
35889
37221
|
}
|
|
35890
37222
|
},
|
|
35891
|
-
locateRecordedSegment: (args) => this.ctx.api.
|
|
35892
|
-
readRecordedSegmentBytes: async (args) => (await this.ctx.api.
|
|
37223
|
+
locateRecordedSegment: (args) => this.ctx.api.recordingArchive.locateSegment.query(args),
|
|
37224
|
+
readRecordedSegmentBytes: async (args) => (await this.ctx.api.recordingArchive.readSegmentBytes.query(args)).data,
|
|
35893
37225
|
readClipBytes: async (args) => {
|
|
35894
37226
|
const res = await this.ctx.api.videoclips.readClipBytes.query({
|
|
35895
37227
|
deviceId: args.deviceId,
|
|
@@ -35922,7 +37254,7 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
35922
37254
|
},
|
|
35923
37255
|
readRecordedGopBytes: async (args) => {
|
|
35924
37256
|
try {
|
|
35925
|
-
return await this.ctx.api.
|
|
37257
|
+
return await this.ctx.api.recordingArchive.readGopBytes.query({
|
|
35926
37258
|
deviceId: args.deviceId,
|
|
35927
37259
|
profile: args.profile,
|
|
35928
37260
|
startMs: args.startMs,
|
|
@@ -35937,7 +37269,7 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
35937
37269
|
});
|
|
35938
37270
|
}
|
|
35939
37271
|
return {
|
|
35940
|
-
data: (await this.ctx.api.
|
|
37272
|
+
data: (await this.ctx.api.recordingArchive.readSegmentBytes.query({
|
|
35941
37273
|
deviceId: args.deviceId,
|
|
35942
37274
|
profile: args.profile,
|
|
35943
37275
|
startMs: args.startMs
|