@camstack/addon-pipeline 1.2.244 → 1.2.245
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-CPwmR_XE.js → default-detection-model-DLFtoi4W.js} +1 -1
- package/dist/{default-detection-model-Be8-TzIO.mjs → default-detection-model-UXQCoRj_.mjs} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-C1PeUaZn.js → dist-CWWQf3ci.js} +50 -2
- package/dist/{dist-D5j2hIVU.mjs → dist-CoZwNgzu.mjs} +50 -2
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-DHN6cJXp.js → node-DLZWItaN.js} +1 -1
- package/dist/{node-CpJcoSCa.mjs → node-Uh59Eo57.mjs} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-DO-CvzV3.mjs → process-memory-BnFnR_nP.mjs} +1 -1
- package/dist/{process-memory-CYMg9OKh.js → process-memory-Bv9SiLn-.js} +1 -1
- package/dist/recorder/index.js +2 -2
- package/dist/recorder/index.mjs +2 -2
- package/dist/{segment-demux-js-D6luJnWU.mjs → segment-demux-js-7DukFIT6.mjs} +1 -1
- package/dist/{segment-demux-js-B7Ot40Oz.js → segment-demux-js-C6B53KjT.js} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CMr_02iL.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B20FCUk4.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DVqDp5VD.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-CJunbWSo.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-Df8n-1_5.mjs → hostInit-DCTAdB0n.mjs} +3 -3
- package/dist/stream-broker/index.js +501 -47
- package/dist/stream-broker/index.mjs +501 -47
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DhmYsj50.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-m_mN1dE-.mjs +0 -26
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import { A as RingBuffer, B as declareLogChannel, Bt as buildFfmpegArgs$1, E as RATE_CONTROL_TIGHT, Et as resolveEgressDecodeHwAccel, Gt as CAM_PROFILE_ORDER, H as deriveBatteryPresence, Ht as isSoftwareDecode, I as batteryCapability, It as webrtcSessionCapability, K as egressTranscodeSharingKey, Kt as DeviceFeature, L as cameraStreamsCapability, Lt as errMsg, M as addonWidgetsSourceCapability, Nt as streamBrokerCapability, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as AUDIO_PRESETS, T as RATE_CONTROL_RELAXED, Ut as BOOT_RECOVERY_BACKOFF_MS, Vt as invocationFromEncodeProfile, Wt as BaseAddon, Yt as createEvent, _n as record, an as parseProfileBrokerId, at as logChannelsCapability, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, ln as array, mn as number, nn as makeSourceBrokerId, pn as literal, q as egressTransportFromRequest, qt as DeviceType, rn as nodePin, st as maskUrlCredentials, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, xt as rankStreams, yn as union, z as createLogChannelsProvider } from "../dist-
|
|
3
|
-
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-
|
|
2
|
+
import { A as RingBuffer, B as declareLogChannel, Bt as buildFfmpegArgs$1, E as RATE_CONTROL_TIGHT, Et as resolveEgressDecodeHwAccel, Gt as CAM_PROFILE_ORDER, H as deriveBatteryPresence, Ht as isSoftwareDecode, I as batteryCapability, It as webrtcSessionCapability, K as egressTranscodeSharingKey, Kt as DeviceFeature, L as cameraStreamsCapability, Lt as errMsg, M as addonWidgetsSourceCapability, Nt as streamBrokerCapability, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as AUDIO_PRESETS, T as RATE_CONTROL_RELAXED, Ut as BOOT_RECOVERY_BACKOFF_MS, Vt as invocationFromEncodeProfile, Wt as BaseAddon, Yt as createEvent, _n as record, an as parseProfileBrokerId, at as logChannelsCapability, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, ln as array, mn as number, nn as makeSourceBrokerId, pn as literal, q as egressTransportFromRequest, qt as DeviceType, rn as nodePin, st as maskUrlCredentials, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, xt as rankStreams, yn as union, z as createLogChannelsProvider } from "../dist-CoZwNgzu.mjs";
|
|
3
|
+
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Uh59Eo57.mjs";
|
|
4
4
|
import { c as PREBUFFER_MAX_MS, d as resolvePrebuffer, i as parseRestreamPath, l as capPreRollRequestMs, n as NO_DECLARATION, u as prebufferRetentionMs } from "../restream-intent-MeJhGCK9.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 { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-
|
|
7
|
+
import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-7DukFIT6.mjs";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import { networkInterfaces, tmpdir } from "node:os";
|
|
@@ -10706,8 +10706,23 @@ var AudioCodecSession = class {
|
|
|
10706
10706
|
var EncodedRingBuffer = class {
|
|
10707
10707
|
packets = [];
|
|
10708
10708
|
maxDurationMs;
|
|
10709
|
-
|
|
10709
|
+
maxBytes;
|
|
10710
|
+
bytes = 0;
|
|
10711
|
+
clampedByBytes = false;
|
|
10712
|
+
/**
|
|
10713
|
+
* @param maxDurationSec how much WALL TIME to keep.
|
|
10714
|
+
* @param maxBytes how much MEMORY that is allowed to cost. `0` = unbounded,
|
|
10715
|
+
* which is what this buffer was before and is why it needed a bound: the
|
|
10716
|
+
* retention is set in SECONDS, but what a second costs depends entirely on
|
|
10717
|
+
* which profile got armed. On the operator's fleet the cheap profile runs
|
|
10718
|
+
* at 34-118 kbps while the main stream runs at up to 11 Mbps — a 25:1
|
|
10719
|
+
* ratio — so one rule arming a clip on the main stream turns a 60-second
|
|
10720
|
+
* retention into 82 MB on a single camera, silently, in native memory.
|
|
10721
|
+
* A time limit cannot express that; only a byte limit can.
|
|
10722
|
+
*/
|
|
10723
|
+
constructor(maxDurationSec = 10, maxBytes = 0) {
|
|
10710
10724
|
this.maxDurationMs = maxDurationSec * 1e3;
|
|
10725
|
+
this.maxBytes = Math.max(0, maxBytes);
|
|
10711
10726
|
}
|
|
10712
10727
|
/** Update the buffer duration (in seconds). Evicts old packets immediately. */
|
|
10713
10728
|
setDuration(seconds) {
|
|
@@ -10721,8 +10736,23 @@ var EncodedRingBuffer = class {
|
|
|
10721
10736
|
/** Push a new packet into the buffer and evict expired ones. */
|
|
10722
10737
|
push(packet) {
|
|
10723
10738
|
this.packets.push(packet);
|
|
10739
|
+
this.bytes += packet.data.length;
|
|
10724
10740
|
this.evict();
|
|
10725
10741
|
}
|
|
10742
|
+
/** What the retained window currently costs, in bytes. */
|
|
10743
|
+
getBufferedBytes() {
|
|
10744
|
+
return this.bytes;
|
|
10745
|
+
}
|
|
10746
|
+
/**
|
|
10747
|
+
* True when the byte budget — not the clock — is what bounds this window.
|
|
10748
|
+
*
|
|
10749
|
+
* Worth asking rather than inferring from a short buffer: a window cut by
|
|
10750
|
+
* bytes holds LESS time than it was configured for, and a consumer that
|
|
10751
|
+
* believes the configured number will look for footage that was never kept.
|
|
10752
|
+
*/
|
|
10753
|
+
isClampedByBytes() {
|
|
10754
|
+
return this.clampedByBytes;
|
|
10755
|
+
}
|
|
10726
10756
|
/**
|
|
10727
10757
|
* Get buffered packets, starting from the most recent keyframe.
|
|
10728
10758
|
* Returns packets in chronological order (oldest first).
|
|
@@ -10764,6 +10794,8 @@ var EncodedRingBuffer = class {
|
|
|
10764
10794
|
/** Clear all buffered packets. */
|
|
10765
10795
|
clear() {
|
|
10766
10796
|
this.packets.length = 0;
|
|
10797
|
+
this.bytes = 0;
|
|
10798
|
+
this.clampedByBytes = false;
|
|
10767
10799
|
}
|
|
10768
10800
|
/**
|
|
10769
10801
|
* Remove packets older than maxDurationMs based on PTS.
|
|
@@ -10784,7 +10816,19 @@ var EncodedRingBuffer = class {
|
|
|
10784
10816
|
const cutoff = newest - this.maxDurationMs;
|
|
10785
10817
|
let removeCount = 0;
|
|
10786
10818
|
while (removeCount < this.packets.length && (this.packets[removeCount].pts < cutoff || this.packets[removeCount].pts > newest)) removeCount++;
|
|
10787
|
-
if (removeCount > 0)
|
|
10819
|
+
if (removeCount > 0) {
|
|
10820
|
+
for (let i = 0; i < removeCount; i++) this.bytes -= this.packets[i].data.length;
|
|
10821
|
+
this.packets.splice(0, removeCount);
|
|
10822
|
+
}
|
|
10823
|
+
if (this.maxBytes > 0 && this.bytes > this.maxBytes) {
|
|
10824
|
+
this.clampedByBytes = true;
|
|
10825
|
+
let dropped = 0;
|
|
10826
|
+
while (dropped < this.packets.length && this.bytes > this.maxBytes) {
|
|
10827
|
+
this.bytes -= this.packets[dropped].data.length;
|
|
10828
|
+
dropped++;
|
|
10829
|
+
}
|
|
10830
|
+
if (dropped > 0) this.packets.splice(0, dropped);
|
|
10831
|
+
} else if (this.maxBytes > 0) this.clampedByBytes = false;
|
|
10788
10832
|
}
|
|
10789
10833
|
};
|
|
10790
10834
|
//#endregion
|
|
@@ -12803,6 +12847,27 @@ var StreamBroker = class StreamBroker {
|
|
|
12803
12847
|
/** Maximum allowed pre-buffer duration in seconds */
|
|
12804
12848
|
static MAX_PRE_BUFFER_SEC = 30;
|
|
12805
12849
|
/**
|
|
12850
|
+
* Ceiling for the notification clip ring, in seconds.
|
|
12851
|
+
*
|
|
12852
|
+
* Separate from `MAX_PRE_BUFFER_SEC` because the two rings live on different
|
|
12853
|
+
* cost axes: the pre-buffer holds the MAIN stream (the fleet's 22 warm
|
|
12854
|
+
* streams sum 107 Mbps), the clip ring holds the cheapest profile (the same
|
|
12855
|
+
* 22 sum 4.3 Mbps — a 25:1 ratio). A minute of the cheap one costs ~32 MB
|
|
12856
|
+
* across the whole fleet; a minute of the main one would cost ~800 MB.
|
|
12857
|
+
*/
|
|
12858
|
+
static MAX_CLIP_RETENTION_SEC = 120;
|
|
12859
|
+
/**
|
|
12860
|
+
* What ONE camera's clip ring may cost, whatever profile ends up armed.
|
|
12861
|
+
*
|
|
12862
|
+
* The same 16 MiB the RTP pre-roll ring already uses, deliberately — one
|
|
12863
|
+
* number for "how much native memory one stream's history may hold". It
|
|
12864
|
+
* holds a full minute of any realistic cheap stream (up to ~2.2 Mbps) and
|
|
12865
|
+
* clamps a rule that arms the MAIN stream to about twelve seconds instead of
|
|
12866
|
+
* letting it take 82 MB. A window cut by this says so
|
|
12867
|
+
* (`isClampedByBytes`).
|
|
12868
|
+
*/
|
|
12869
|
+
static CLIP_RING_MAX_BYTES = 16 * 1024 * 1024;
|
|
12870
|
+
/**
|
|
12806
12871
|
* Retention for the decoder-seed GOP ring. Only ever needs to hold the
|
|
12807
12872
|
* current GOP (a fresh keyframe evicts the previous GOP on read), so a few
|
|
12808
12873
|
* seconds covers every realistic keyframe interval; cameras with a GOP longer
|
|
@@ -14580,15 +14645,31 @@ var StreamBroker = class StreamBroker {
|
|
|
14580
14645
|
}
|
|
14581
14646
|
/** Retain `seconds` of AnnexB video for notification clips (0 = stop, frees the ring). */
|
|
14582
14647
|
setClipRetention(seconds) {
|
|
14583
|
-
const clamped = Math.max(0, Math.min(seconds, StreamBroker.
|
|
14648
|
+
const clamped = Math.max(0, Math.min(seconds, StreamBroker.MAX_CLIP_RETENTION_SEC));
|
|
14584
14649
|
if (clamped === this.clipRetentionSec) return;
|
|
14650
|
+
const previous = this.clipRetentionSec;
|
|
14585
14651
|
this.clipRetentionSec = clamped;
|
|
14652
|
+
this.logger?.info(clamped === 0 ? "Clip retention off" : "Clip retention set", { meta: {
|
|
14653
|
+
seconds: clamped,
|
|
14654
|
+
requested: seconds,
|
|
14655
|
+
previousSeconds: previous
|
|
14656
|
+
} });
|
|
14586
14657
|
if (clamped === 0) {
|
|
14587
14658
|
this.clipBuffer = null;
|
|
14588
14659
|
return;
|
|
14589
14660
|
}
|
|
14590
14661
|
if (this.clipBuffer) this.clipBuffer.setDuration(clamped);
|
|
14591
|
-
else this.clipBuffer = new EncodedRingBuffer(clamped);
|
|
14662
|
+
else this.clipBuffer = new EncodedRingBuffer(clamped, StreamBroker.CLIP_RING_MAX_BYTES);
|
|
14663
|
+
}
|
|
14664
|
+
/**
|
|
14665
|
+
* Seconds this stream's clip ring is currently holding. `0` = no ring.
|
|
14666
|
+
*
|
|
14667
|
+
* A READ of what was applied, for the manager's own bound (how long a cut may
|
|
14668
|
+
* wait for its post-roll) and for the tests that pin the retention a camera's
|
|
14669
|
+
* notification rules ask for.
|
|
14670
|
+
*/
|
|
14671
|
+
getClipRetentionSec() {
|
|
14672
|
+
return this.clipRetentionSec;
|
|
14592
14673
|
}
|
|
14593
14674
|
/**
|
|
14594
14675
|
* The retained clip window, oldest first. Empty when retention is off.
|
|
@@ -16221,13 +16302,57 @@ function formatClockHHMM(at) {
|
|
|
16221
16302
|
* reach `streaming` with audio detected, and the stats poll cadence. */
|
|
16222
16303
|
var PROBE_STREAM_TIMEOUT_MS = 6e3;
|
|
16223
16304
|
var PROBE_STREAM_POLL_MS = 250;
|
|
16224
|
-
|
|
16305
|
+
/**
|
|
16306
|
+
* How much history a notification clip may be cut from on a camera the
|
|
16307
|
+
* notification centre has **not spoken about yet**.
|
|
16308
|
+
*
|
|
16309
|
+
* Retention is no longer flat: every camera holds what its own notification
|
|
16310
|
+
* rules ask for ({@link refreshClipRetentionPlan}), which is nothing at all on
|
|
16311
|
+
* a camera whose rules never cut footage. This number is what a camera holds
|
|
16312
|
+
* while that ask is UNKNOWN — before the first successful pull, and after a
|
|
16313
|
+
* pull that failed or answered with a rule set that has not loaded. Absence
|
|
16314
|
+
* must not read as zero: dropping a ring on boot, before the rules arrive,
|
|
16315
|
+
* loses the footage for exactly the first events after every restart, which is
|
|
16316
|
+
* when an operator is most likely to be watching. So an unknown camera keeps
|
|
16317
|
+
* what the whole fleet used to keep, and only an ANSWER can take it away.
|
|
16318
|
+
*
|
|
16319
|
+
* Sixty seconds, raised from 25 on 2026-09-15 after measuring what the
|
|
16320
|
+
* alternative sources can actually serve. The recordings' availability edge is
|
|
16321
|
+
* not a number but a PHASE: asking for the same 10-second window at different
|
|
16322
|
+
* anchors, full coverage arrived at −18 s on one camera and −26 s on two
|
|
16323
|
+
* others, depending on where the request falls in the 10-second segment cycle.
|
|
16324
|
+
* A notification window is `[anchor−4, anchor+6]`, so at 25 s the ring served
|
|
16325
|
+
* an anchor roughly 6–19 s old while the recordings began at 14–26 s — and on
|
|
16326
|
+
* an unlucky phase those bands did not meet, which is the silent "no clip" the
|
|
16327
|
+
* operator kept receiving. No fixed threshold closes a gap that opens and
|
|
16328
|
+
* closes with the segment cycle; margin over the WORST phase does.
|
|
16329
|
+
*
|
|
16330
|
+
* It is affordable only because this ring runs on the cheap profile: the
|
|
16331
|
+
* fleet's 22 cheap streams sum 4.3 Mbps, so a minute costs ~32 MB across every
|
|
16332
|
+
* camera — against ~800 MB for the same minute of the main stream. And it is
|
|
16333
|
+
* SAFE only because the ring is now bounded by bytes
|
|
16334
|
+
* (`StreamBroker.CLIP_RING_MAX_BYTES`): a rule that arms a clip on the main
|
|
16335
|
+
* stream is clamped to what 16 MiB holds rather than taking 82 MB.
|
|
16336
|
+
*/
|
|
16337
|
+
var CLIP_RETENTION_UNKNOWN_SEC = 60;
|
|
16225
16338
|
/** Cheapest-first — the profile a clip is cut from when the rule says nothing. */
|
|
16226
16339
|
var CLIP_PROFILE_BY_COST = [
|
|
16227
16340
|
"low",
|
|
16228
16341
|
"mid",
|
|
16229
16342
|
"high"
|
|
16230
16343
|
];
|
|
16344
|
+
/**
|
|
16345
|
+
* Head-room added to an OBSERVED ask (`preRoll + postRoll + this`).
|
|
16346
|
+
*
|
|
16347
|
+
* The notification centre derives the same shape from its own rules
|
|
16348
|
+
* (`NC_CLIP_DISPATCH_MARGIN_SEC`) and for the same reason: a cut is taken
|
|
16349
|
+
* seconds after the moment it is about — the row is queued, drained, and its
|
|
16350
|
+
* still is waited for — so a ring sized to the window alone drops the pre-roll
|
|
16351
|
+
* a heartbeat before the renderer asks for it. Deliberately NOT imported from
|
|
16352
|
+
* the notification addon: addons never import each other, and this is the
|
|
16353
|
+
* broker's own floor for a consumer that is not the notification centre.
|
|
16354
|
+
*/
|
|
16355
|
+
var CLIP_OBSERVED_ASK_MARGIN_SEC = 10;
|
|
16231
16356
|
/** How long `renderPreBufferClip` will wait for the post-roll to accumulate. */
|
|
16232
16357
|
var CLIP_POST_ROLL_POLL_MS = 250;
|
|
16233
16358
|
/**
|
|
@@ -18101,6 +18226,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18101
18226
|
} else this.disabledDevices.delete(d.id);
|
|
18102
18227
|
const live = cameras.filter((d) => !d.disabled);
|
|
18103
18228
|
await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id, "poll")));
|
|
18229
|
+
await this.refreshClipRetentionPlan();
|
|
18104
18230
|
await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
|
|
18105
18231
|
const liveIds = new Set(live.map((d) => d.id));
|
|
18106
18232
|
for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
|
|
@@ -18639,8 +18765,8 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18639
18765
|
const assigned = CLIP_PROFILE_BY_COST.filter((p) => map[p] !== void 0);
|
|
18640
18766
|
const wanted = pickClipProfile(assigned, input.profile);
|
|
18641
18767
|
if (!wanted) throw new Error(`No profile assigned for device ${input.deviceId}`);
|
|
18642
|
-
this.
|
|
18643
|
-
const until = Math.min(input.aroundMs + input.postRollSec * 1e3, Date.now() +
|
|
18768
|
+
this.noteClipAsk(input.deviceId, wanted, input.preRollSec + input.postRollSec + CLIP_OBSERVED_ASK_MARGIN_SEC);
|
|
18769
|
+
const until = Math.min(input.aroundMs + input.postRollSec * 1e3, Date.now() + this.clipRetentionSecFor(input.deviceId) * 1e3);
|
|
18644
18770
|
while (Date.now() < until) await new Promise((resolve) => setTimeout(resolve, CLIP_POST_ROLL_POLL_MS));
|
|
18645
18771
|
const candidates = [wanted, ...assigned.filter((p) => p !== wanted)];
|
|
18646
18772
|
let profile = null;
|
|
@@ -20362,8 +20488,18 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20362
20488
|
this.applyClipRetention(deviceId);
|
|
20363
20489
|
}
|
|
20364
20490
|
/**
|
|
20365
|
-
*
|
|
20366
|
-
*
|
|
20491
|
+
* Apply the retention THIS camera's notification rules actually ask for.
|
|
20492
|
+
*
|
|
20493
|
+
* Three inputs, one number:
|
|
20494
|
+
*
|
|
20495
|
+
* 1. the PLAN — `notificationRules.getClipRetentionAsks`, the rules' own
|
|
20496
|
+
* demand per camera ({@link refreshClipRetentionPlan});
|
|
20497
|
+
* 2. what a cut has been OBSERVED asking for on this camera
|
|
20498
|
+
* ({@link noteClipAsk}) — the old reactive arming, folded into the same
|
|
20499
|
+
* ask so there is one mechanism rather than two;
|
|
20500
|
+
* 3. nothing at all, when the plan has never arrived — see
|
|
20501
|
+
* {@link CLIP_RETENTION_UNKNOWN_SEC}. An unknown camera is not a camera
|
|
20502
|
+
* that needs nothing.
|
|
20367
20503
|
*
|
|
20368
20504
|
* Retention is opportunistic: it never dials a camera, it only fills while
|
|
20369
20505
|
* something else already holds the stream up (usually the detection pipeline
|
|
@@ -20372,32 +20508,159 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20372
20508
|
*/
|
|
20373
20509
|
applyClipRetention(deviceId) {
|
|
20374
20510
|
const map = this.assignments.get(deviceId)?.map ?? {};
|
|
20375
|
-
const
|
|
20376
|
-
const armed = this.clipRetentionArmed.get(deviceId);
|
|
20511
|
+
const resolved = this.resolveClipRetention(deviceId, map);
|
|
20377
20512
|
for (const profile of CAM_PROFILE_ORDER) {
|
|
20378
20513
|
const camStreamId = map[profile];
|
|
20379
20514
|
if (!camStreamId) continue;
|
|
20380
|
-
const wanted =
|
|
20381
|
-
this.brokers.get(brokerIdFor(deviceId, camStreamId))?.setClipRetention(wanted
|
|
20515
|
+
const wanted = resolved.profiles.has(profile) ? resolved.seconds : 0;
|
|
20516
|
+
this.brokers.get(brokerIdFor(deviceId, camStreamId))?.setClipRetention(wanted);
|
|
20517
|
+
}
|
|
20518
|
+
this.reportClipRetention(deviceId, resolved);
|
|
20519
|
+
}
|
|
20520
|
+
/**
|
|
20521
|
+
* The seconds and the profiles this camera's ring should hold, and WHY.
|
|
20522
|
+
*
|
|
20523
|
+
* The MAX over every ask that reaches the camera — the asks describe the
|
|
20524
|
+
* rules, the bounding belongs here (and, below this, to the ring's own byte
|
|
20525
|
+
* budget, which is never raised for an ask).
|
|
20526
|
+
*/
|
|
20527
|
+
resolveClipRetention(deviceId, map) {
|
|
20528
|
+
const cheapest = CLIP_PROFILE_BY_COST.find((p) => map[p] !== void 0);
|
|
20529
|
+
const observed = this.clipRetentionObserved.get(deviceId);
|
|
20530
|
+
const profiles = /* @__PURE__ */ new Set();
|
|
20531
|
+
let seconds = 0;
|
|
20532
|
+
let source;
|
|
20533
|
+
if (this.clipRetentionAsks === null) {
|
|
20534
|
+
seconds = CLIP_RETENTION_UNKNOWN_SEC;
|
|
20535
|
+
source = "unknown";
|
|
20536
|
+
} else {
|
|
20537
|
+
source = "rules";
|
|
20538
|
+
for (const ask of this.clipRetentionAsks) {
|
|
20539
|
+
if (ask.deviceId !== void 0 && ask.deviceId !== deviceId) continue;
|
|
20540
|
+
if (ask.seconds > seconds) seconds = ask.seconds;
|
|
20541
|
+
if (ask.profile !== void 0) profiles.add(ask.profile);
|
|
20542
|
+
}
|
|
20543
|
+
}
|
|
20544
|
+
if (observed !== void 0) for (const [profile, asked] of observed) {
|
|
20545
|
+
if (asked > seconds) {
|
|
20546
|
+
seconds = asked;
|
|
20547
|
+
source = source === "unknown" ? "unknown" : "observed";
|
|
20548
|
+
}
|
|
20549
|
+
profiles.add(profile);
|
|
20382
20550
|
}
|
|
20551
|
+
if (seconds <= 0) return {
|
|
20552
|
+
seconds: 0,
|
|
20553
|
+
profiles: /* @__PURE__ */ new Set(),
|
|
20554
|
+
source
|
|
20555
|
+
};
|
|
20556
|
+
if (cheapest !== void 0) profiles.add(cheapest);
|
|
20557
|
+
return {
|
|
20558
|
+
seconds,
|
|
20559
|
+
profiles,
|
|
20560
|
+
source
|
|
20561
|
+
};
|
|
20562
|
+
}
|
|
20563
|
+
/**
|
|
20564
|
+
* Say it, once per transition, per camera.
|
|
20565
|
+
*
|
|
20566
|
+
* This mechanism MOVES — a rule created, disabled or deleted changes what a
|
|
20567
|
+
* camera holds within one reconcile tick — and a retention that changes
|
|
20568
|
+
* silently is a retention nobody can correlate with a missing clip. The
|
|
20569
|
+
* broker's own `setClipRetention` logs the buffer it allocated; this line
|
|
20570
|
+
* says who asked for it.
|
|
20571
|
+
*/
|
|
20572
|
+
reportClipRetention(deviceId, resolved) {
|
|
20573
|
+
const signature = `${resolved.seconds}:${resolved.source}:${[...resolved.profiles].sort().join(",")}`;
|
|
20574
|
+
const previous = this.clipRetentionApplied.get(deviceId);
|
|
20575
|
+
if (previous === signature) return;
|
|
20576
|
+
this.clipRetentionApplied.set(deviceId, signature);
|
|
20577
|
+
this.logger.info(resolved.seconds === 0 ? "clip retention: none — no notification rule on this camera cuts footage" : "clip retention set from the notification rules", {
|
|
20578
|
+
tags: { deviceId },
|
|
20579
|
+
meta: {
|
|
20580
|
+
seconds: resolved.seconds,
|
|
20581
|
+
profiles: [...resolved.profiles],
|
|
20582
|
+
source: resolved.source,
|
|
20583
|
+
previous: previous ?? "none"
|
|
20584
|
+
}
|
|
20585
|
+
});
|
|
20586
|
+
}
|
|
20587
|
+
/** Seconds this camera's ring is currently asked to hold (0 when none). */
|
|
20588
|
+
clipRetentionSecFor(deviceId) {
|
|
20589
|
+
return this.resolveClipRetention(deviceId, this.assignments.get(deviceId)?.map ?? {}).seconds;
|
|
20383
20590
|
}
|
|
20384
20591
|
/**
|
|
20385
|
-
*
|
|
20386
|
-
*
|
|
20592
|
+
* PULL the notification centre's per-camera asks and apply them.
|
|
20593
|
+
*
|
|
20594
|
+
* A pull, not a push: a camera's ring lives on the node that ingests it while
|
|
20595
|
+
* `notification-rules` is a singleton, so a push would have to enumerate the
|
|
20596
|
+
* cluster. It also makes the failure mode right — a pull that fails, or one
|
|
20597
|
+
* answered by a centre whose rules have not loaded, changes NOTHING (D49: a
|
|
20598
|
+
* read that fails must never be able to destroy work, and dropping a ring is
|
|
20599
|
+
* destroying work). Only an answer about a rule set that was actually read
|
|
20600
|
+
* can take retention away.
|
|
20387
20601
|
*
|
|
20388
|
-
*
|
|
20389
|
-
*
|
|
20390
|
-
*
|
|
20391
|
-
* and looked like the choice did nothing.
|
|
20602
|
+
* Rides the catalog reconcile, which is also the D8 backstop that re-derives
|
|
20603
|
+
* after a rule is created, edited, enabled, disabled or deleted — bounded
|
|
20604
|
+
* staleness (one tick), never a mirror that only follows the rules at boot.
|
|
20392
20605
|
*/
|
|
20606
|
+
async refreshClipRetentionPlan() {
|
|
20607
|
+
const api = this.api;
|
|
20608
|
+
if (!api) return;
|
|
20609
|
+
let plan;
|
|
20610
|
+
try {
|
|
20611
|
+
plan = await api.notificationRules.getClipRetentionAsks.query({});
|
|
20612
|
+
} catch (err) {
|
|
20613
|
+
this.logger.debug("clip retention plan unavailable — keeping the one in force", { meta: {
|
|
20614
|
+
error: errMsg(err),
|
|
20615
|
+
known: this.clipRetentionAsks !== null
|
|
20616
|
+
} });
|
|
20617
|
+
return;
|
|
20618
|
+
}
|
|
20619
|
+
if (!plan.rulesLoaded) {
|
|
20620
|
+
this.logger.debug("clip retention plan: the notification centre has not read its rules yet", { meta: { known: this.clipRetentionAsks !== null } });
|
|
20621
|
+
return;
|
|
20622
|
+
}
|
|
20623
|
+
this.clipRetentionAsks = plan.asks;
|
|
20624
|
+
for (const deviceId of this.assignments.keys()) this.applyClipRetention(deviceId);
|
|
20625
|
+
}
|
|
20393
20626
|
/** Produced event artifacts waiting to be fetched by handle (D9/D18). */
|
|
20394
20627
|
eventMedia = new EventMediaStore();
|
|
20395
|
-
|
|
20396
|
-
|
|
20397
|
-
|
|
20398
|
-
|
|
20399
|
-
|
|
20400
|
-
|
|
20628
|
+
/**
|
|
20629
|
+
* The notification rules' own asks, per camera. `null` = never answered.
|
|
20630
|
+
*
|
|
20631
|
+
* `null` and `[]` are DIFFERENT and the difference is a camera's footage:
|
|
20632
|
+
* `[]` means the centre read its rules and none of them cut footage here,
|
|
20633
|
+
* `null` means nobody has said. Only the first one is allowed to zero a ring.
|
|
20634
|
+
*/
|
|
20635
|
+
clipRetentionAsks = null;
|
|
20636
|
+
/**
|
|
20637
|
+
* What a cut was OBSERVED asking for, per camera and profile.
|
|
20638
|
+
*
|
|
20639
|
+
* The old `armClipRetention`, folded into the ask above so retention has ONE
|
|
20640
|
+
* input shape. It still exists because the plan describes the notification
|
|
20641
|
+
* rules and a cut can come from elsewhere (a clips API, an export, a rule
|
|
20642
|
+
* created a second ago on another node): a consumer that asked for a window
|
|
20643
|
+
* this ring did not hold gets it next time. It is REMEMBERED because the
|
|
20644
|
+
* reconcile re-applies retention on a timer, and without that the arming was
|
|
20645
|
+
* wiped within the minute — a rule asking for `high` fell back to `low`
|
|
20646
|
+
* forever and looked like the choice did nothing.
|
|
20647
|
+
*/
|
|
20648
|
+
clipRetentionObserved = /* @__PURE__ */ new Map();
|
|
20649
|
+
/** Last reported (seconds, source, profiles) per camera — transitions only. */
|
|
20650
|
+
clipRetentionApplied = /* @__PURE__ */ new Map();
|
|
20651
|
+
/**
|
|
20652
|
+
* A cut asked this camera for `seconds` on `profile` — remember it.
|
|
20653
|
+
*
|
|
20654
|
+
* ONE ask, with both halves: the reactive arming used to carry the profile
|
|
20655
|
+
* and inherit the flat duration, so a consumer that needed a longer window
|
|
20656
|
+
* had no way to say so and a consumer that needed none still paid.
|
|
20657
|
+
*/
|
|
20658
|
+
noteClipAsk(deviceId, profile, seconds) {
|
|
20659
|
+
const observed = this.clipRetentionObserved.get(deviceId) ?? /* @__PURE__ */ new Map();
|
|
20660
|
+
const bounded = Math.max(0, Math.min(seconds, CLIP_RETENTION_UNKNOWN_SEC));
|
|
20661
|
+
if ((observed.get(profile) ?? 0) >= bounded && this.clipRetentionObserved.has(deviceId)) return;
|
|
20662
|
+
observed.set(profile, Math.max(observed.get(profile) ?? 0, bounded));
|
|
20663
|
+
this.clipRetentionObserved.set(deviceId, observed);
|
|
20401
20664
|
this.applyClipRetention(deviceId);
|
|
20402
20665
|
}
|
|
20403
20666
|
countPushConsumers(map, deviceId) {
|
|
@@ -20697,6 +20960,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20697
20960
|
return;
|
|
20698
20961
|
}
|
|
20699
20962
|
this.brokers.set(brokerId, broker);
|
|
20963
|
+
this.applyClipRetention(deviceId);
|
|
20700
20964
|
const existingToken = this.persistedTokens.get(brokerId);
|
|
20701
20965
|
const warmUp = () => broker.onEncodedData(() => {});
|
|
20702
20966
|
this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken);
|
|
@@ -24273,6 +24537,18 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
24273
24537
|
}
|
|
24274
24538
|
static MAX_RTP_PAYLOAD = 1100;
|
|
24275
24539
|
rtpPacketsSent = 0;
|
|
24540
|
+
/**
|
|
24541
|
+
* Cumulative RTP packets this session has put on the wire.
|
|
24542
|
+
*
|
|
24543
|
+
* Read by the adaptive ladder's stall detector: "the client received
|
|
24544
|
+
* nothing" only means the link is broken if the broker was SENDING at the
|
|
24545
|
+
* time. Without this half, a paused camera, a battery source between wakes
|
|
24546
|
+
* or any legitimately idle feed reads as a stall and walks the ladder to
|
|
24547
|
+
* the floor for nothing.
|
|
24548
|
+
*/
|
|
24549
|
+
get rtpPacketsSentTotal() {
|
|
24550
|
+
return this.rtpPacketsSent;
|
|
24551
|
+
}
|
|
24276
24552
|
rtpLogCounter = 0;
|
|
24277
24553
|
/** One-shot guards for the "first video/audio RTP" debug markers. */
|
|
24278
24554
|
firstVideoRtpLogged = false;
|
|
@@ -25060,6 +25336,105 @@ var LossTracker = class {
|
|
|
25060
25336
|
}
|
|
25061
25337
|
};
|
|
25062
25338
|
//#endregion
|
|
25339
|
+
//#region src/stream-broker/webrtc/stall-tracker.ts
|
|
25340
|
+
/**
|
|
25341
|
+
* Windowed "the media stopped arriving" detector. Pure state machine: no
|
|
25342
|
+
* clock of its own, no side effects — `observe` is the only mutator and every
|
|
25343
|
+
* decision is a function of the samples it was given.
|
|
25344
|
+
*/
|
|
25345
|
+
var StallTracker = class {
|
|
25346
|
+
config;
|
|
25347
|
+
/** When the current uninterrupted stall began; null when not stalling. */
|
|
25348
|
+
stallStartedAt = null;
|
|
25349
|
+
blackoutUntil = 0;
|
|
25350
|
+
lastEvidence = {
|
|
25351
|
+
clientKbps: null,
|
|
25352
|
+
stalledForMs: 0,
|
|
25353
|
+
serverSending: false
|
|
25354
|
+
};
|
|
25355
|
+
constructor(config) {
|
|
25356
|
+
this.config = config;
|
|
25357
|
+
}
|
|
25358
|
+
/** A tier change just happened — ignore the re-offer's own dead air. */
|
|
25359
|
+
markChanged(now) {
|
|
25360
|
+
this.stallStartedAt = null;
|
|
25361
|
+
this.blackoutUntil = now + this.config.waitAfterChangeMs;
|
|
25362
|
+
}
|
|
25363
|
+
/**
|
|
25364
|
+
* Fold one evaluation into the window. Returns nothing; ask `isStalled`.
|
|
25365
|
+
*
|
|
25366
|
+
* Three outcomes, and the middle one is the reason this is not a simple
|
|
25367
|
+
* boolean: a rate at or above `recoverKbpsFloor` CLEARS the stall, a rate
|
|
25368
|
+
* below `stallKbpsFloor` starts or extends it, and anything between the two
|
|
25369
|
+
* (or unknown, or with the server idle) HOLDS whatever the window already
|
|
25370
|
+
* believed. Holding is what stops a borderline link from oscillating.
|
|
25371
|
+
*/
|
|
25372
|
+
observe(sample, now) {
|
|
25373
|
+
if (now < this.blackoutUntil) {
|
|
25374
|
+
this.stallStartedAt = null;
|
|
25375
|
+
this.lastEvidence = {
|
|
25376
|
+
clientKbps: sample.clientKbps,
|
|
25377
|
+
stalledForMs: 0,
|
|
25378
|
+
serverSending: sample.serverSending
|
|
25379
|
+
};
|
|
25380
|
+
return;
|
|
25381
|
+
}
|
|
25382
|
+
const kbps = sample.clientKbps;
|
|
25383
|
+
const age = sample.clientSampleAgeMs;
|
|
25384
|
+
if (!(kbps !== null && Number.isFinite(kbps) && age !== null && age <= this.config.maxClientSampleAgeMs) || kbps === null) {
|
|
25385
|
+
this.lastEvidence = {
|
|
25386
|
+
clientKbps: null,
|
|
25387
|
+
stalledForMs: this.stalledForMs(now),
|
|
25388
|
+
serverSending: sample.serverSending
|
|
25389
|
+
};
|
|
25390
|
+
return;
|
|
25391
|
+
}
|
|
25392
|
+
if (kbps >= this.config.recoverKbpsFloor) this.stallStartedAt = null;
|
|
25393
|
+
else if (kbps < this.config.stallKbpsFloor && sample.serverSending) this.stallStartedAt ??= now;
|
|
25394
|
+
this.lastEvidence = {
|
|
25395
|
+
clientKbps: kbps,
|
|
25396
|
+
stalledForMs: this.stalledForMs(now),
|
|
25397
|
+
serverSending: sample.serverSending
|
|
25398
|
+
};
|
|
25399
|
+
}
|
|
25400
|
+
stalledForMs(now) {
|
|
25401
|
+
return this.stallStartedAt === null ? 0 : now - this.stallStartedAt;
|
|
25402
|
+
}
|
|
25403
|
+
/** True once the stall has persisted for `stallConfirmMs`. */
|
|
25404
|
+
isStalled(now) {
|
|
25405
|
+
if (now < this.blackoutUntil) return false;
|
|
25406
|
+
return this.stalledForMs(now) >= this.config.stallConfirmMs;
|
|
25407
|
+
}
|
|
25408
|
+
/**
|
|
25409
|
+
* May the ladder PROMOTE on this evaluation?
|
|
25410
|
+
*
|
|
25411
|
+
* False when the last fresh measurement was below `recoverKbpsFloor` —
|
|
25412
|
+
* either a stall building, or a link crawling along in the dead band.
|
|
25413
|
+
* Promoting there spends bandwidth the session has just been shown not to
|
|
25414
|
+
* have, and the loss path cannot object: a stream delivering almost nothing
|
|
25415
|
+
* reports almost no loss. Same doctrine as `LossTracker.hasEvidence` — a
|
|
25416
|
+
* climb needs POSITIVE evidence, not the absence of a complaint.
|
|
25417
|
+
*
|
|
25418
|
+
* Unknown does not block. A deployment with no client telemetry at all must
|
|
25419
|
+
* behave exactly as it did before this tracker existed; the loss path's own
|
|
25420
|
+
* `hasEvidence` is the gate there.
|
|
25421
|
+
*/
|
|
25422
|
+
isClimbSafe(now) {
|
|
25423
|
+
if (now < this.blackoutUntil) return true;
|
|
25424
|
+
if (this.stallStartedAt !== null) return false;
|
|
25425
|
+
const last = this.lastEvidence.clientKbps;
|
|
25426
|
+
if (last === null) return true;
|
|
25427
|
+
return last >= this.config.recoverKbpsFloor;
|
|
25428
|
+
}
|
|
25429
|
+
/** What the last `observe` measured — for the switch's log line. */
|
|
25430
|
+
evidence(now) {
|
|
25431
|
+
return {
|
|
25432
|
+
...this.lastEvidence,
|
|
25433
|
+
stalledForMs: this.stalledForMs(now)
|
|
25434
|
+
};
|
|
25435
|
+
}
|
|
25436
|
+
};
|
|
25437
|
+
//#endregion
|
|
25063
25438
|
//#region src/stream-broker/webrtc/adaptive-controller.ts
|
|
25064
25439
|
var TIER_ORDER = CAM_PROFILE_ORDER;
|
|
25065
25440
|
/**
|
|
@@ -25081,6 +25456,7 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
|
|
|
25081
25456
|
var AdaptiveController = class {
|
|
25082
25457
|
opts;
|
|
25083
25458
|
tracker;
|
|
25459
|
+
stalls;
|
|
25084
25460
|
tier;
|
|
25085
25461
|
mtuReduced = false;
|
|
25086
25462
|
transcodeEngaged = false;
|
|
@@ -25099,6 +25475,7 @@ var AdaptiveController = class {
|
|
|
25099
25475
|
constructor(opts) {
|
|
25100
25476
|
this.opts = opts;
|
|
25101
25477
|
this.tracker = new LossTracker(opts.config);
|
|
25478
|
+
this.stalls = new StallTracker(opts.config);
|
|
25102
25479
|
this.tier = opts.initialTier;
|
|
25103
25480
|
this.failures = new Map(opts.priorFailures ?? []);
|
|
25104
25481
|
this.upgradeCount = Math.max(0, opts.priorUpgradeCount ?? 0);
|
|
@@ -25121,6 +25498,19 @@ var AdaptiveController = class {
|
|
|
25121
25498
|
this.tracker.track(sample, now);
|
|
25122
25499
|
}
|
|
25123
25500
|
/**
|
|
25501
|
+
* Fold one TRANSPORT measurement into the ladder — what the client actually
|
|
25502
|
+
* received, and whether the broker was sending at the time.
|
|
25503
|
+
*
|
|
25504
|
+
* This is the half the ladder was missing. Packet loss cannot see a freeze
|
|
25505
|
+
* (a stream delivering nothing reports 0 % loss, because there are no gaps
|
|
25506
|
+
* in a sequence that received no packets), and the client's downlink hint
|
|
25507
|
+
* cannot see one either — it is a one-shot HTTP burst measured at connect
|
|
25508
|
+
* time, on a path the media does not take. See `stall-tracker.ts`.
|
|
25509
|
+
*/
|
|
25510
|
+
onThroughput(sample, now) {
|
|
25511
|
+
this.stalls.observe(sample, now);
|
|
25512
|
+
}
|
|
25513
|
+
/**
|
|
25124
25514
|
* Freeze the ladder for this evaluation instead of ticking it.
|
|
25125
25515
|
*
|
|
25126
25516
|
* Called while the session's track is NOT carrying the live source — D42
|
|
@@ -25141,6 +25531,7 @@ var AdaptiveController = class {
|
|
|
25141
25531
|
this.lastChangeAt = now;
|
|
25142
25532
|
}
|
|
25143
25533
|
this.healthySince = now;
|
|
25534
|
+
this.stalls.markChanged(now);
|
|
25144
25535
|
}
|
|
25145
25536
|
tick(now) {
|
|
25146
25537
|
if (!this.armed) {
|
|
@@ -25148,9 +25539,18 @@ var AdaptiveController = class {
|
|
|
25148
25539
|
this.healthySince = now;
|
|
25149
25540
|
this.lastChangeAt = now;
|
|
25150
25541
|
}
|
|
25542
|
+
if (this.stalls.isStalled(now)) {
|
|
25543
|
+
this.healthySince = now;
|
|
25544
|
+
this.handleFailing(now, "stall");
|
|
25545
|
+
return;
|
|
25546
|
+
}
|
|
25151
25547
|
if (this.tracker.isFailing(now)) {
|
|
25152
25548
|
this.healthySince = now;
|
|
25153
|
-
this.handleFailing(now);
|
|
25549
|
+
this.handleFailing(now, "loss");
|
|
25550
|
+
return;
|
|
25551
|
+
}
|
|
25552
|
+
if (!this.stalls.isClimbSafe(now)) {
|
|
25553
|
+
this.healthySince = now;
|
|
25154
25554
|
return;
|
|
25155
25555
|
}
|
|
25156
25556
|
if (!this.tracker.hasEvidence(now)) {
|
|
@@ -25176,26 +25576,36 @@ var AdaptiveController = class {
|
|
|
25176
25576
|
commitChange(now) {
|
|
25177
25577
|
this.lastChangeAt = now;
|
|
25178
25578
|
this.tracker.markChanged(now);
|
|
25579
|
+
this.stalls.markChanged(now);
|
|
25580
|
+
}
|
|
25581
|
+
/** The measurement this decision rests on, snapshotted for the log line. */
|
|
25582
|
+
decisionAt(now, cause) {
|
|
25583
|
+
return {
|
|
25584
|
+
cause,
|
|
25585
|
+
lossRatio: this.tracker.lossRatio(),
|
|
25586
|
+
stall: this.stalls.evidence(now)
|
|
25587
|
+
};
|
|
25179
25588
|
}
|
|
25180
|
-
handleFailing(now) {
|
|
25589
|
+
handleFailing(now, cause) {
|
|
25590
|
+
const decision = this.decisionAt(now, cause);
|
|
25181
25591
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
25182
25592
|
if (idx < TIER_ORDER.length - 1) {
|
|
25183
25593
|
const failed = this.tier;
|
|
25184
25594
|
this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
|
|
25185
25595
|
this.tier = TIER_ORDER[idx + 1];
|
|
25186
|
-
this.opts.onSwitchTier(this.tier);
|
|
25596
|
+
this.opts.onSwitchTier(this.tier, decision);
|
|
25187
25597
|
this.commitChange(now);
|
|
25188
25598
|
return;
|
|
25189
25599
|
}
|
|
25190
25600
|
if (!this.mtuReduced) {
|
|
25191
25601
|
this.mtuReduced = true;
|
|
25192
|
-
this.opts.onReduceMtu();
|
|
25602
|
+
this.opts.onReduceMtu(decision);
|
|
25193
25603
|
this.commitChange(now);
|
|
25194
25604
|
return;
|
|
25195
25605
|
}
|
|
25196
25606
|
if (!this.transcodeEngaged) {
|
|
25197
25607
|
this.transcodeEngaged = true;
|
|
25198
|
-
this.opts.onTranscodeDown();
|
|
25608
|
+
this.opts.onTranscodeDown(decision);
|
|
25199
25609
|
this.commitChange(now);
|
|
25200
25610
|
}
|
|
25201
25611
|
}
|
|
@@ -25209,7 +25619,7 @@ var AdaptiveController = class {
|
|
|
25209
25619
|
this.mtuReduced = false;
|
|
25210
25620
|
this.transcodeEngaged = false;
|
|
25211
25621
|
this.upgradeCount += 1;
|
|
25212
|
-
this.opts.onSwitchTier(next);
|
|
25622
|
+
this.opts.onSwitchTier(next, this.decisionAt(now, "healthy"));
|
|
25213
25623
|
this.commitChange(now);
|
|
25214
25624
|
this.healthySince = now;
|
|
25215
25625
|
}
|
|
@@ -29350,7 +29760,12 @@ var ADAPTIVE_CONFIG = {
|
|
|
29350
29760
|
upgradeHealthyGrowthFactor: 3,
|
|
29351
29761
|
maxProgressiveUpgradeHealthyMs: 3e4,
|
|
29352
29762
|
minTimeBetweenChangesMs: 4e3,
|
|
29353
|
-
maxUpgradeBackoffMs: 9e5
|
|
29763
|
+
maxUpgradeBackoffMs: 9e5,
|
|
29764
|
+
stallKbpsFloor: 64,
|
|
29765
|
+
stallConfirmMs: 6e3,
|
|
29766
|
+
recoverKbpsFloor: 128,
|
|
29767
|
+
waitAfterChangeMs: 8e3,
|
|
29768
|
+
maxClientSampleAgeMs: 1e4
|
|
29354
29769
|
};
|
|
29355
29770
|
/**
|
|
29356
29771
|
* True when `broker` is an in-process StreamBroker exposing the Opus egress
|
|
@@ -29494,7 +29909,7 @@ var BrokerWebrtcServer = class {
|
|
|
29494
29909
|
staticIceServers;
|
|
29495
29910
|
icePortRange;
|
|
29496
29911
|
iceAdditionalHostAddresses;
|
|
29497
|
-
|
|
29912
|
+
clientNetworkStats;
|
|
29498
29913
|
locateRecordedSegment;
|
|
29499
29914
|
readRecordedSegmentBytes;
|
|
29500
29915
|
readRecordedGopBytes;
|
|
@@ -29561,7 +29976,7 @@ var BrokerWebrtcServer = class {
|
|
|
29561
29976
|
this.staticIceServers = options.iceServers;
|
|
29562
29977
|
this.icePortRange = options.icePortRange;
|
|
29563
29978
|
this.iceAdditionalHostAddresses = options.iceAdditionalHostAddresses;
|
|
29564
|
-
this.
|
|
29979
|
+
this.clientNetworkStats = options.clientNetworkStats;
|
|
29565
29980
|
this.locateRecordedSegment = options.locateRecordedSegment;
|
|
29566
29981
|
this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
|
|
29567
29982
|
this.readRecordedGopBytes = options.readRecordedGopBytes;
|
|
@@ -30499,12 +30914,27 @@ var BrokerWebrtcServer = class {
|
|
|
30499
30914
|
priorFailures,
|
|
30500
30915
|
priorUpgradeCount,
|
|
30501
30916
|
config: ADAPTIVE_CONFIG,
|
|
30502
|
-
onSwitchTier: (tier) => {
|
|
30503
|
-
this.switchSessionTier(entry, deviceId, tier, sessionId);
|
|
30917
|
+
onSwitchTier: (tier, decision) => {
|
|
30918
|
+
this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
|
|
30504
30919
|
},
|
|
30505
|
-
onReduceMtu: () =>
|
|
30506
|
-
|
|
30507
|
-
this.
|
|
30920
|
+
onReduceMtu: (decision) => {
|
|
30921
|
+
entry.session.setRepacketizerMtu(AdaptiveSession.ADAPTIVE_LOW_MTU);
|
|
30922
|
+
this.logger.info("adaptive: reducing repacketizer MTU", {
|
|
30923
|
+
tags: { deviceId },
|
|
30924
|
+
meta: {
|
|
30925
|
+
sessionId,
|
|
30926
|
+
deviceId,
|
|
30927
|
+
mtu: AdaptiveSession.ADAPTIVE_LOW_MTU,
|
|
30928
|
+
reason: decision.cause,
|
|
30929
|
+
lossRatio: Number(decision.lossRatio.toFixed(4)),
|
|
30930
|
+
clientKbps: decision.stall.clientKbps,
|
|
30931
|
+
stalledForMs: decision.stall.stalledForMs,
|
|
30932
|
+
serverSending: decision.stall.serverSending
|
|
30933
|
+
}
|
|
30934
|
+
});
|
|
30935
|
+
},
|
|
30936
|
+
onTranscodeDown: (decision) => {
|
|
30937
|
+
this.switchSessionTier(entry, deviceId, "low", sessionId, true, decision);
|
|
30508
30938
|
}
|
|
30509
30939
|
});
|
|
30510
30940
|
entry.session.onReceiverReport((s) => controller.onLoss(s, Date.now()));
|
|
@@ -30512,6 +30942,9 @@ var BrokerWebrtcServer = class {
|
|
|
30512
30942
|
let clientHighestSequence = 0;
|
|
30513
30943
|
let clientPacketsLost = 0;
|
|
30514
30944
|
let tickInFlight = false;
|
|
30945
|
+
/** RTP packets sent as of the previous evaluation — the "was the broker
|
|
30946
|
+
* sending?" half of the stall verdict. */
|
|
30947
|
+
let lastRtpSent = entry.session.rtpPacketsSentTotal;
|
|
30515
30948
|
const CLIENT_LOSS_SPAN = 1e3;
|
|
30516
30949
|
entry.adaptiveTimer = setInterval(() => {
|
|
30517
30950
|
if (tickInFlight) return;
|
|
@@ -30520,10 +30953,20 @@ var BrokerWebrtcServer = class {
|
|
|
30520
30953
|
try {
|
|
30521
30954
|
if (entry.timeline?.currentMode === "recorded") {
|
|
30522
30955
|
controller.suspend(Date.now());
|
|
30956
|
+
lastRtpSent = entry.session.rtpPacketsSentTotal;
|
|
30523
30957
|
return;
|
|
30524
30958
|
}
|
|
30525
|
-
const
|
|
30959
|
+
const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
|
|
30526
30960
|
const now = Date.now();
|
|
30961
|
+
const rtpSent = entry.session.rtpPacketsSentTotal;
|
|
30962
|
+
const serverSending = rtpSent > lastRtpSent;
|
|
30963
|
+
lastRtpSent = rtpSent;
|
|
30964
|
+
controller.onThroughput({
|
|
30965
|
+
clientKbps: client ? client.estimatedBandwidthKbps : null,
|
|
30966
|
+
clientSampleAgeMs: client ? Math.max(0, now - client.lastUpdated) : null,
|
|
30967
|
+
serverSending
|
|
30968
|
+
}, now);
|
|
30969
|
+
const pct = client?.packetLossPercent ?? null;
|
|
30527
30970
|
if (pct !== null && pct !== void 0) {
|
|
30528
30971
|
clientHighestSequence += CLIENT_LOSS_SPAN;
|
|
30529
30972
|
clientPacketsLost += Math.round(Math.max(0, Math.min(100, pct)) / 100 * CLIENT_LOSS_SPAN);
|
|
@@ -30552,7 +30995,7 @@ var BrokerWebrtcServer = class {
|
|
|
30552
30995
|
* consumes the recorded intent to pin the recreated session to `tier` and
|
|
30553
30996
|
* apply the reduced transcode profile when `forceTranscodeDown` is set.
|
|
30554
30997
|
*/
|
|
30555
|
-
switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
|
|
30998
|
+
switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false, decision) {
|
|
30556
30999
|
const prev = this.adaptiveIntent.get(sessionId);
|
|
30557
31000
|
const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
|
|
30558
31001
|
const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
|
|
@@ -30576,7 +31019,12 @@ var BrokerWebrtcServer = class {
|
|
|
30576
31019
|
forceTranscodeDown,
|
|
30577
31020
|
epoch,
|
|
30578
31021
|
failures: Object.fromEntries(failures),
|
|
30579
|
-
upgradeCount
|
|
31022
|
+
upgradeCount,
|
|
31023
|
+
reason: decision?.cause ?? "unknown",
|
|
31024
|
+
lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
|
|
31025
|
+
clientKbps: decision?.stall.clientKbps ?? null,
|
|
31026
|
+
stalledForMs: decision?.stall.stalledForMs ?? null,
|
|
31027
|
+
serverSending: decision?.stall.serverSending ?? null
|
|
30580
31028
|
}
|
|
30581
31029
|
});
|
|
30582
31030
|
}
|
|
@@ -31648,11 +32096,17 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
31648
32096
|
return [];
|
|
31649
32097
|
}
|
|
31650
32098
|
},
|
|
31651
|
-
|
|
32099
|
+
clientNetworkStats: async (deviceId) => {
|
|
31652
32100
|
const ctrl = new AbortController();
|
|
31653
32101
|
const timer = setTimeout(() => ctrl.abort(), NETWORK_QUALITY_STATS_TIMEOUT_MS);
|
|
31654
32102
|
try {
|
|
31655
|
-
|
|
32103
|
+
const client = (await this.ctx.api.networkQuality.getDeviceStats.query({ deviceId }, { signal: ctrl.signal }))?.client;
|
|
32104
|
+
if (!client) return null;
|
|
32105
|
+
return {
|
|
32106
|
+
packetLossPercent: client.packetLossPercent,
|
|
32107
|
+
estimatedBandwidthKbps: client.estimatedBandwidthKbps,
|
|
32108
|
+
lastUpdated: client.lastUpdated
|
|
32109
|
+
};
|
|
31656
32110
|
} catch {
|
|
31657
32111
|
return null;
|
|
31658
32112
|
} finally {
|