@camstack/addon-pipeline 1.2.38 → 1.2.39
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 +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +145 -175
- package/dist/detection-pipeline/index.mjs +120 -150
- package/dist/{dist-BvFLZyhW.js → dist-BtAvtroH.js} +260 -108
- package/dist/{dist-Du7_SNiR.mjs → dist-COgGdFMv.mjs} +260 -108
- package/dist/{step-definitions-C8tk9fPJ.mjs → event-loop-stall-monitor-naFYvcOO.mjs} +139 -2
- package/dist/{step-definitions-i8O-hxyI.js → event-loop-stall-monitor-shfwi1_b.js} +150 -1
- package/dist/lazy-sharp-B-mb8uWx.mjs +20 -0
- package/dist/lazy-sharp-CcYuXtsa.js +25 -0
- package/dist/motion-wasm/index.js +3 -3
- package/dist/motion-wasm/index.mjs +3 -3
- package/dist/pipeline-runner/index.js +3316 -2428
- package/dist/pipeline-runner/index.mjs +3315 -2428
- package/dist/recorder/index.js +102 -664
- package/dist/recorder/index.mjs +102 -658
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ByuMjhL3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CjrKd4xX.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B59GNAJq.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-83qNzMSL.mjs +26 -0
- package/dist/stream-broker/{hostInit-CfC64ye2.mjs → hostInit-BFH17kSk.mjs} +2 -2
- package/dist/stream-broker/index.js +717 -211
- package/dist/stream-broker/index.mjs +717 -211
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js → MaskShapeCanvas-DI4BY7W2-yeSHfpUy.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-9D-BWwQP.js → MotionZonesSettings-NcxxQN8r-Df4WxM6N.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-HvAetfA4.js → PrivacyMaskSettings-APgPLF7p-COHSiTGq.js} +1 -1
- package/embed-dist/assets/index-4fJzjj4H.css +2 -0
- package/embed-dist/assets/index-Ct9M2v5n.js +112 -0
- package/embed-dist/index.html +2 -2
- 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-ByHi7ZAe.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-CscpvIRR.mjs +0 -26
- package/embed-dist/assets/index-9Ob2YrIf.js +0 -343
- package/embed-dist/assets/index-CAe6Dg-X.css +0 -2
- package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
- package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
- package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
- package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
|
@@ -3,13 +3,13 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
|
-
const require_dist = require("../dist-
|
|
6
|
+
const require_dist = require("../dist-BtAvtroH.js");
|
|
7
7
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
8
8
|
const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
|
|
9
|
-
let node_os = require("node:os");
|
|
10
|
-
node_os = require_chunk.__toESM(node_os);
|
|
11
9
|
let node_child_process = require("node:child_process");
|
|
12
10
|
let node_url = require("node:url");
|
|
11
|
+
let node_os = require("node:os");
|
|
12
|
+
node_os = require_chunk.__toESM(node_os);
|
|
13
13
|
let node_fs = require("node:fs");
|
|
14
14
|
node_fs = require_chunk.__toESM(node_fs);
|
|
15
15
|
let node_path = require("node:path");
|
|
@@ -3035,6 +3035,22 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3035
3035
|
/** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
|
|
3036
3036
|
rtpBootstrap;
|
|
3037
3037
|
rtpBootstrapDone = false;
|
|
3038
|
+
/**
|
|
3039
|
+
* Answered-but-silent guard. The answer deliberately returns WITHOUT
|
|
3040
|
+
* awaiting source bring-up (bare trickle answer, background dial); the
|
|
3041
|
+
* failure shape that buys is a connected client staring at a black player
|
|
3042
|
+
* because the source never delivered. `sourceMediaSeen` flips on the FIRST
|
|
3043
|
+
* evidence of source media reaching this session — a source RTP packet
|
|
3044
|
+
* (`forwardSourceRtpVideo`, even before DTLS lets it transmit) or a video
|
|
3045
|
+
* NAL write (`writeVideoNals`, the AnnexB + transcode egress) — and the
|
|
3046
|
+
* watchdog armed at feed start warns ONCE if the budget elapses first.
|
|
3047
|
+
*/
|
|
3048
|
+
sourceMediaSeen = false;
|
|
3049
|
+
sourceSilenceTimer = null;
|
|
3050
|
+
/** Budget between "feeding started" (post-answer, post-DTLS) and the first
|
|
3051
|
+
* source media. Chosen above a healthy cold dial's worst case (~2–3 s) and
|
|
3052
|
+
* below the point a viewer gives up. */
|
|
3053
|
+
static SOURCE_SILENT_AFTER_ANSWER_MS = 5e3;
|
|
3038
3054
|
/** In-flight paced pre-buffer replay (instant-start bootstrap or PLI
|
|
3039
3055
|
* recovery). While active, live source-RTP packets are enqueued behind
|
|
3040
3056
|
* the replay tail to preserve packet order. Aborted on close/rebind. */
|
|
@@ -3500,8 +3516,10 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3500
3516
|
* compatible answer. This avoids werift transceiver direction issues.
|
|
3501
3517
|
*/
|
|
3502
3518
|
async handleOffer(clientOffer, opts) {
|
|
3519
|
+
const negotiateStart = Date.now();
|
|
3503
3520
|
const { werift, pcOptions } = await this.buildPcOptions();
|
|
3504
3521
|
this.pc = new werift.RTCPeerConnection(pcOptions);
|
|
3522
|
+
const pcSetupMs = Date.now() - negotiateStart;
|
|
3505
3523
|
this.wireControlChannel();
|
|
3506
3524
|
this.pc.iceConnectionStateChange.subscribe((state) => {
|
|
3507
3525
|
this.logger.info("ICE state", { meta: {
|
|
@@ -3534,7 +3552,9 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3534
3552
|
sdpMLineIndex: c.sdpMLineIndex ?? null
|
|
3535
3553
|
});
|
|
3536
3554
|
});
|
|
3555
|
+
const mdnsStart = Date.now();
|
|
3537
3556
|
const resolvedOfferSdp = await resolveMdnsCandidatesInSdp(clientOffer.sdp, this.logger, `session:${this.sessionId}`);
|
|
3557
|
+
const mdnsMs = Date.now() - mdnsStart;
|
|
3538
3558
|
const offerCandidates = resolvedOfferSdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
|
|
3539
3559
|
if (this.debug) this.logger.info("client offer REMOTE ICE candidates", { meta: {
|
|
3540
3560
|
phase: "session",
|
|
@@ -3545,7 +3565,9 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3545
3565
|
candidates: offerCandidates
|
|
3546
3566
|
} });
|
|
3547
3567
|
const remoteDesc = new werift.RTCSessionDescription(resolvedOfferSdp, clientOffer.type);
|
|
3568
|
+
const setRemoteStart = Date.now();
|
|
3548
3569
|
await this.pc.setRemoteDescription(remoteDesc);
|
|
3570
|
+
const setRemoteMs = Date.now() - setRemoteStart;
|
|
3549
3571
|
const transceivers = this.pc.getTransceivers();
|
|
3550
3572
|
for (const t of transceivers) {
|
|
3551
3573
|
const kind = t.receiver?.track?.kind ?? t.kind;
|
|
@@ -3594,7 +3616,9 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3594
3616
|
const audioTransceiver = this.pc.addTransceiver(this.audioTrack, { direction: "sendonly" });
|
|
3595
3617
|
this.audioSender = audioTransceiver.sender;
|
|
3596
3618
|
}
|
|
3619
|
+
const createAnswerStart = Date.now();
|
|
3597
3620
|
const answerDesc = await this.pc.createAnswer();
|
|
3621
|
+
const createAnswerMs = Date.now() - createAnswerStart;
|
|
3598
3622
|
let finalSdp;
|
|
3599
3623
|
if (opts?.nonTrickle === true) {
|
|
3600
3624
|
await this.pc.setLocalDescription(answerDesc).catch((e) => {
|
|
@@ -3622,9 +3646,15 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3622
3646
|
this.logger.info("Codec negotiated (client-offer)", { meta: {
|
|
3623
3647
|
phase: "session",
|
|
3624
3648
|
sessionId: this.sessionId,
|
|
3649
|
+
deviceId: this.deviceId,
|
|
3625
3650
|
source: this.sourceCodec,
|
|
3626
3651
|
negotiated: this.negotiatedCodec,
|
|
3627
|
-
needsTranscode: this.needsTranscode
|
|
3652
|
+
needsTranscode: this.needsTranscode,
|
|
3653
|
+
negotiateMs: Date.now() - negotiateStart,
|
|
3654
|
+
pcSetupMs,
|
|
3655
|
+
mdnsMs,
|
|
3656
|
+
setRemoteMs,
|
|
3657
|
+
createAnswerMs
|
|
3628
3658
|
} });
|
|
3629
3659
|
if (this.debug) this.logger.info("Codec negotiation detail (client-offer)", { meta: {
|
|
3630
3660
|
phase: "session",
|
|
@@ -3774,6 +3804,10 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3774
3804
|
clearInterval(this.statsTimer);
|
|
3775
3805
|
this.statsTimer = null;
|
|
3776
3806
|
}
|
|
3807
|
+
if (this.sourceSilenceTimer) {
|
|
3808
|
+
clearTimeout(this.sourceSilenceTimer);
|
|
3809
|
+
this.sourceSilenceTimer = null;
|
|
3810
|
+
}
|
|
3777
3811
|
if (this.feedAbort) {
|
|
3778
3812
|
this.feedAbort.abort();
|
|
3779
3813
|
this.feedAbort = null;
|
|
@@ -3871,6 +3905,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3871
3905
|
*/
|
|
3872
3906
|
forwardSourceRtpVideo(rtpData) {
|
|
3873
3907
|
if (this.closed) return;
|
|
3908
|
+
this.sourceMediaSeen = true;
|
|
3874
3909
|
const codec = this.negotiatedCodec;
|
|
3875
3910
|
if (codec !== "H265" && codec !== "H264") return;
|
|
3876
3911
|
if (!this.videoSender || !_werift) return;
|
|
@@ -3882,6 +3917,12 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3882
3917
|
this.startPacedRtpReplay(bootstrap, codec, "replayed source-RTP pre-buffer (instant start)");
|
|
3883
3918
|
return;
|
|
3884
3919
|
}
|
|
3920
|
+
this.logger.warn("instant-start pre-buffer empty — viewer waits for the next source IDR", { meta: {
|
|
3921
|
+
phase: "session",
|
|
3922
|
+
sessionId: this.sessionId,
|
|
3923
|
+
deviceId: this.deviceId,
|
|
3924
|
+
codec
|
|
3925
|
+
} });
|
|
3885
3926
|
}
|
|
3886
3927
|
if (this.activeRtpReplay?.active && this.activeRtpReplay.enqueue(rtpData)) return;
|
|
3887
3928
|
this.sendSourceRtpPacket(rtpData, codec);
|
|
@@ -4074,6 +4115,27 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
4074
4115
|
} });
|
|
4075
4116
|
this.writeVideoNals(this.lastKeyframeNals, this.sendRtpTimestamp(), this.negotiatedCodec);
|
|
4076
4117
|
}
|
|
4118
|
+
/**
|
|
4119
|
+
* Arm the answered-but-silent watchdog: if no source media has reached
|
|
4120
|
+
* this session `SOURCE_SILENT_AFTER_ANSWER_MS` after feeding started, one
|
|
4121
|
+
* named WARN fires. Idempotent; disarmed by `close()`. Public so the guard
|
|
4122
|
+
* is testable without standing up werift.
|
|
4123
|
+
*/
|
|
4124
|
+
armSourceSilenceWatchdog() {
|
|
4125
|
+
if (this.sourceSilenceTimer || this.closed) return;
|
|
4126
|
+
this.sourceSilenceTimer = setTimeout(() => {
|
|
4127
|
+
this.sourceSilenceTimer = null;
|
|
4128
|
+
if (this.closed || this.sourceMediaSeen) return;
|
|
4129
|
+
this.logger.warn("answered but NO source media reached the session — source dial stalled or camera silent", { meta: {
|
|
4130
|
+
phase: "session",
|
|
4131
|
+
sessionId: this.sessionId,
|
|
4132
|
+
deviceId: this.deviceId,
|
|
4133
|
+
budgetMs: AdaptiveSession.SOURCE_SILENT_AFTER_ANSWER_MS,
|
|
4134
|
+
needsTranscode: this.needsTranscode
|
|
4135
|
+
} });
|
|
4136
|
+
}, AdaptiveSession.SOURCE_SILENT_AFTER_ANSWER_MS);
|
|
4137
|
+
this.sourceSilenceTimer.unref?.();
|
|
4138
|
+
}
|
|
4077
4139
|
startFeedingFrames() {
|
|
4078
4140
|
this.feedAbort = new AbortController();
|
|
4079
4141
|
const { signal } = this.feedAbort;
|
|
@@ -4083,6 +4145,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
4083
4145
|
dtls: this.videoSender?.dtlsTransport?.state ?? "unknown",
|
|
4084
4146
|
needsTranscode: this.needsTranscode
|
|
4085
4147
|
} });
|
|
4148
|
+
this.armSourceSilenceWatchdog();
|
|
4086
4149
|
if (this.needsTranscode) this.startTranscodeFeed(signal);
|
|
4087
4150
|
else this.startDirectFeed(signal);
|
|
4088
4151
|
}
|
|
@@ -4658,6 +4721,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
4658
4721
|
});
|
|
4659
4722
|
}
|
|
4660
4723
|
writeVideoNals(nals, rtpTs, codec) {
|
|
4724
|
+
this.sourceMediaSeen = true;
|
|
4661
4725
|
if (!this.videoSender || !_werift) {
|
|
4662
4726
|
if (this.rtpLogCounter === 0) {
|
|
4663
4727
|
this.logger.warn("writeVideoNals: no sender", { meta: {
|
|
@@ -9735,6 +9799,21 @@ var DecoderSessionProxy = class {
|
|
|
9735
9799
|
this.api = api;
|
|
9736
9800
|
this.sessionId = sessionId;
|
|
9737
9801
|
}
|
|
9802
|
+
/**
|
|
9803
|
+
* Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
|
|
9804
|
+
* regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
|
|
9805
|
+
* `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
|
|
9806
|
+
* is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
|
|
9807
|
+
* peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
|
|
9808
|
+
* stub, an old decoder that predates `waitMs`, a test fake — would otherwise
|
|
9809
|
+
* turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
|
|
9810
|
+
* that pins the CPU, starves every timer in the process, and allocates until
|
|
9811
|
+
* the heap dies (observed: a 4 GB OOM in under 15 s).
|
|
9812
|
+
*/
|
|
9813
|
+
async paceEmptyDrain(drainStartedAt) {
|
|
9814
|
+
const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
|
|
9815
|
+
if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
|
|
9816
|
+
}
|
|
9738
9817
|
async pushPacket(packet) {
|
|
9739
9818
|
await this.api.pushPacket({
|
|
9740
9819
|
sessionId: this.sessionId,
|
|
@@ -9750,12 +9829,14 @@ var DecoderSessionProxy = class {
|
|
|
9750
9829
|
async startPolling(onFrame) {
|
|
9751
9830
|
this.polling = true;
|
|
9752
9831
|
while (this.polling) {
|
|
9832
|
+
const drainStartedAt = Date.now();
|
|
9753
9833
|
const frames = await this.api.pullFrames({
|
|
9754
9834
|
sessionId: this.sessionId,
|
|
9755
9835
|
maxCount: 4,
|
|
9756
9836
|
waitMs: POLL_WAIT_MS
|
|
9757
9837
|
});
|
|
9758
9838
|
for (const frame of frames) onFrame(frame);
|
|
9839
|
+
if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
|
|
9759
9840
|
}
|
|
9760
9841
|
}
|
|
9761
9842
|
/**
|
|
@@ -9773,12 +9854,14 @@ var DecoderSessionProxy = class {
|
|
|
9773
9854
|
async startHandlePolling(onHandle) {
|
|
9774
9855
|
this.polling = true;
|
|
9775
9856
|
while (this.polling) try {
|
|
9857
|
+
const drainStartedAt = Date.now();
|
|
9776
9858
|
const handles = await this.api.pullHandles({
|
|
9777
9859
|
sessionId: this.sessionId,
|
|
9778
9860
|
maxCount: 4,
|
|
9779
9861
|
waitMs: POLL_WAIT_MS
|
|
9780
9862
|
});
|
|
9781
9863
|
for (const handle of handles) onHandle(handle);
|
|
9864
|
+
if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
|
|
9782
9865
|
} catch (err) {
|
|
9783
9866
|
this.polling = false;
|
|
9784
9867
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -17784,6 +17867,71 @@ function withSourceResolution(consumerDefault, probedSource) {
|
|
|
17784
17867
|
};
|
|
17785
17868
|
}
|
|
17786
17869
|
//#endregion
|
|
17870
|
+
//#region src/stream-broker/webrtc/broker-linger-registry.ts
|
|
17871
|
+
/** Matches the observed reopen gaps (1–2 min) with headroom; well under the
|
|
17872
|
+
* "operator walked away" scale where warmth would be waste. */
|
|
17873
|
+
var DEFAULT_LINGER_MS = 3 * 6e4;
|
|
17874
|
+
var BrokerLingerRegistry = class {
|
|
17875
|
+
lingerMs;
|
|
17876
|
+
subscribe;
|
|
17877
|
+
holds = /* @__PURE__ */ new Map();
|
|
17878
|
+
constructor(options) {
|
|
17879
|
+
this.lingerMs = options.lingerMs ?? DEFAULT_LINGER_MS;
|
|
17880
|
+
this.subscribe = options.subscribe;
|
|
17881
|
+
}
|
|
17882
|
+
/**
|
|
17883
|
+
* Start (or restart) the linger window for a broker that just lost its
|
|
17884
|
+
* last session. Re-holding an already-held broker re-arms the timer
|
|
17885
|
+
* without stacking a second ghost subscription.
|
|
17886
|
+
*/
|
|
17887
|
+
hold(brokerId) {
|
|
17888
|
+
const existing = this.holds.get(brokerId);
|
|
17889
|
+
if (existing) {
|
|
17890
|
+
clearTimeout(existing.timer);
|
|
17891
|
+
existing.timer = this.armTimer(brokerId);
|
|
17892
|
+
return;
|
|
17893
|
+
}
|
|
17894
|
+
const unsubscribe = this.subscribe(brokerId);
|
|
17895
|
+
if (unsubscribe === null) return;
|
|
17896
|
+
this.holds.set(brokerId, {
|
|
17897
|
+
unsubscribe,
|
|
17898
|
+
timer: this.armTimer(brokerId)
|
|
17899
|
+
});
|
|
17900
|
+
}
|
|
17901
|
+
/** Release the hold NOW — a new session attached, the real subscription
|
|
17902
|
+
* carries the demand from here. No-op when nothing is held. */
|
|
17903
|
+
cancel(brokerId) {
|
|
17904
|
+
const hold = this.holds.get(brokerId);
|
|
17905
|
+
if (!hold) return;
|
|
17906
|
+
clearTimeout(hold.timer);
|
|
17907
|
+
this.holds.delete(brokerId);
|
|
17908
|
+
this.release(hold);
|
|
17909
|
+
}
|
|
17910
|
+
/** Release every hold (server shutdown). */
|
|
17911
|
+
stop() {
|
|
17912
|
+
for (const [brokerId, hold] of this.holds) {
|
|
17913
|
+
clearTimeout(hold.timer);
|
|
17914
|
+
this.holds.delete(brokerId);
|
|
17915
|
+
this.release(hold);
|
|
17916
|
+
}
|
|
17917
|
+
}
|
|
17918
|
+
armTimer(brokerId) {
|
|
17919
|
+
const timer = setTimeout(() => {
|
|
17920
|
+
const hold = this.holds.get(brokerId);
|
|
17921
|
+
if (!hold) return;
|
|
17922
|
+
this.holds.delete(brokerId);
|
|
17923
|
+
this.release(hold);
|
|
17924
|
+
}, this.lingerMs);
|
|
17925
|
+
timer.unref?.();
|
|
17926
|
+
return timer;
|
|
17927
|
+
}
|
|
17928
|
+
release(hold) {
|
|
17929
|
+
try {
|
|
17930
|
+
hold.unsubscribe();
|
|
17931
|
+
} catch {}
|
|
17932
|
+
}
|
|
17933
|
+
};
|
|
17934
|
+
//#endregion
|
|
17787
17935
|
//#region src/stream-broker/webrtc/loss-tracker.ts
|
|
17788
17936
|
/** Delta between two RTCP extended (32-bit) highest-sequence values, with
|
|
17789
17937
|
* 32-bit wraparound handling. */
|
|
@@ -17832,6 +17980,24 @@ var LossTracker = class {
|
|
|
17832
17980
|
if (now < this.resetUntil) return false;
|
|
17833
17981
|
return this.lossRatio() > this.config.minFailPercent;
|
|
17834
17982
|
}
|
|
17983
|
+
/**
|
|
17984
|
+
* True when the window holds enough recent receiver feedback for a
|
|
17985
|
+
* "healthy" verdict to MEAN anything.
|
|
17986
|
+
*
|
|
17987
|
+
* `lossRatio()` returns 0 with fewer than two in-window samples, so
|
|
17988
|
+
* `isFailing` is false for a session that has produced NO feedback at all —
|
|
17989
|
+
* a link so bad that RTCP never arrives reads identically to a perfect one.
|
|
17990
|
+
* A downgrade may act on that (0 loss ⇒ don't downgrade, which is safe); an
|
|
17991
|
+
* UPGRADE must not, because it spends bandwidth the session has never been
|
|
17992
|
+
* shown to have. Silence is not health.
|
|
17993
|
+
*/
|
|
17994
|
+
hasEvidence(now) {
|
|
17995
|
+
if (now < this.resetUntil) return false;
|
|
17996
|
+
const cutoff = now - this.config.analyzeWindowMs;
|
|
17997
|
+
let inWindow = 0;
|
|
17998
|
+
for (const s of this.samples) if (s.time > cutoff) inWindow += 1;
|
|
17999
|
+
return inWindow >= 2;
|
|
18000
|
+
}
|
|
17835
18001
|
};
|
|
17836
18002
|
//#endregion
|
|
17837
18003
|
//#region src/stream-broker/webrtc/adaptive-controller.ts
|
|
@@ -17840,8 +18006,17 @@ var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
|
|
|
17840
18006
|
* Ladder state machine for an adaptive WebRTC session. Fed loss samples
|
|
17841
18007
|
* (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
|
|
17842
18008
|
* Downgrade order: tier high→mid→low, then MTU reduction at low, then
|
|
17843
|
-
* transcode-down.
|
|
17844
|
-
*
|
|
18009
|
+
* transcode-down. Side effects run through injected callbacks (testable
|
|
18010
|
+
* without werift).
|
|
18011
|
+
*
|
|
18012
|
+
* An UPGRADE requires all three of:
|
|
18013
|
+
* 1. the session clock — measured from the FIRST tick, not from the unix
|
|
18014
|
+
* epoch — showing `upgradeHealthyMs` without a failing verdict;
|
|
18015
|
+
* 2. actual receiver feedback in the window (`LossTracker.hasEvidence`) —
|
|
18016
|
+
* a session that has produced no RTCP at all is not "healthy", it is
|
|
18017
|
+
* silent, and climbing on silence spends bandwidth never shown to exist;
|
|
18018
|
+
* 3. the target tier's re-attempt backoff, which doubles per measured
|
|
18019
|
+
* failure of that tier in this session.
|
|
17845
18020
|
*/
|
|
17846
18021
|
var AdaptiveController = class {
|
|
17847
18022
|
opts;
|
|
@@ -17851,27 +18026,86 @@ var AdaptiveController = class {
|
|
|
17851
18026
|
transcodeEngaged = false;
|
|
17852
18027
|
lastChangeAt = 0;
|
|
17853
18028
|
healthySince = 0;
|
|
18029
|
+
/**
|
|
18030
|
+
* Whether the session clock has been armed by the first evaluation. The
|
|
18031
|
+
* clock is `Date.now()`, so leaving `healthySince` / `lastChangeAt` at 0
|
|
18032
|
+
* would read as "healthy since 1970" and hand out a free upgrade on the
|
|
18033
|
+
* very first tick, before a single loss sample had been seen. A boolean
|
|
18034
|
+
* (not a `startedAt === 0` sentinel) because 0 is a legitimate clock value.
|
|
18035
|
+
*/
|
|
18036
|
+
armed = false;
|
|
18037
|
+
failures;
|
|
17854
18038
|
constructor(opts) {
|
|
17855
18039
|
this.opts = opts;
|
|
17856
18040
|
this.tracker = new LossTracker(opts.config);
|
|
17857
18041
|
this.tier = opts.initialTier;
|
|
18042
|
+
this.failures = new Map(opts.priorFailures ?? []);
|
|
17858
18043
|
}
|
|
17859
18044
|
get currentTier() {
|
|
17860
18045
|
return this.tier;
|
|
17861
18046
|
}
|
|
18047
|
+
/**
|
|
18048
|
+
* Per-tier measured-failure counts, INCLUDING those inherited from earlier
|
|
18049
|
+
* rungs of the same viewer session. The server snapshots this into the
|
|
18050
|
+
* adaptive intent so the session's next controller inherits it.
|
|
18051
|
+
*/
|
|
18052
|
+
get failureCounts() {
|
|
18053
|
+
return this.failures;
|
|
18054
|
+
}
|
|
17862
18055
|
onLoss(sample, now) {
|
|
17863
18056
|
this.tracker.track(sample, now);
|
|
17864
18057
|
}
|
|
18058
|
+
/**
|
|
18059
|
+
* Freeze the ladder for this evaluation instead of ticking it.
|
|
18060
|
+
*
|
|
18061
|
+
* Called while the session's track is NOT carrying the live source — D42
|
|
18062
|
+
* put recorded playback and scrub on the same track with a moving source
|
|
18063
|
+
* pointer, and in those modes the live feed is gated, so receiver feedback
|
|
18064
|
+
* measures the recorded feeder, not the camera tier. Acting on it is
|
|
18065
|
+
* incoherent in both directions: a downgrade cannot shrink a file, an
|
|
18066
|
+
* upgrade cannot enlarge one, and EITHER destroys the feeder (a tier switch
|
|
18067
|
+
* is a close-and-recreate re-offer) — dropping the viewer back to live at
|
|
18068
|
+
* the wrong moment with nothing to restore the pointer.
|
|
18069
|
+
*
|
|
18070
|
+
* Holds the climb clock so time spent in recorded modes can never
|
|
18071
|
+
* accumulate into an upgrade on the return to live.
|
|
18072
|
+
*/
|
|
18073
|
+
suspend(now) {
|
|
18074
|
+
if (!this.armed) {
|
|
18075
|
+
this.armed = true;
|
|
18076
|
+
this.lastChangeAt = now;
|
|
18077
|
+
}
|
|
18078
|
+
this.healthySince = now;
|
|
18079
|
+
}
|
|
17865
18080
|
tick(now) {
|
|
18081
|
+
if (!this.armed) {
|
|
18082
|
+
this.armed = true;
|
|
18083
|
+
this.healthySince = now;
|
|
18084
|
+
this.lastChangeAt = now;
|
|
18085
|
+
}
|
|
17866
18086
|
if (this.tracker.isFailing(now)) {
|
|
17867
18087
|
this.healthySince = now;
|
|
17868
18088
|
this.handleFailing(now);
|
|
17869
18089
|
return;
|
|
17870
18090
|
}
|
|
18091
|
+
if (!this.tracker.hasEvidence(now)) {
|
|
18092
|
+
this.healthySince = now;
|
|
18093
|
+
return;
|
|
18094
|
+
}
|
|
17871
18095
|
this.handleHealthy(now);
|
|
17872
18096
|
}
|
|
17873
18097
|
canUpgrade(now) {
|
|
17874
|
-
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs
|
|
18098
|
+
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
|
|
18099
|
+
}
|
|
18100
|
+
/**
|
|
18101
|
+
* How long the link must read healthy before `tier` may be re-attempted.
|
|
18102
|
+
* Doubles per measured failure of that tier in this session, capped at
|
|
18103
|
+
* `maxUpgradeBackoffMs`.
|
|
18104
|
+
*/
|
|
18105
|
+
requiredHealthyMs(tier) {
|
|
18106
|
+
const failures = this.failures.get(tier) ?? 0;
|
|
18107
|
+
if (failures <= 0) return this.opts.config.upgradeHealthyMs;
|
|
18108
|
+
return Math.min(this.opts.config.upgradeHealthyMs * 2 ** failures, this.opts.config.maxUpgradeBackoffMs);
|
|
17875
18109
|
}
|
|
17876
18110
|
commitChange(now) {
|
|
17877
18111
|
this.lastChangeAt = now;
|
|
@@ -17880,6 +18114,8 @@ var AdaptiveController = class {
|
|
|
17880
18114
|
handleFailing(now) {
|
|
17881
18115
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
17882
18116
|
if (idx < TIER_ORDER.length - 1) {
|
|
18117
|
+
const failed = this.tier;
|
|
18118
|
+
this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
|
|
17883
18119
|
this.tier = TIER_ORDER[idx + 1];
|
|
17884
18120
|
this.opts.onSwitchTier(this.tier);
|
|
17885
18121
|
this.commitChange(now);
|
|
@@ -17898,17 +18134,17 @@ var AdaptiveController = class {
|
|
|
17898
18134
|
}
|
|
17899
18135
|
}
|
|
17900
18136
|
handleHealthy(now) {
|
|
17901
|
-
if (now - this.healthySince < this.opts.config.upgradeHealthyMs) return;
|
|
17902
|
-
if (!this.canUpgrade(now)) return;
|
|
17903
18137
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
17904
|
-
if (idx
|
|
17905
|
-
|
|
17906
|
-
|
|
17907
|
-
|
|
17908
|
-
|
|
17909
|
-
|
|
17910
|
-
|
|
17911
|
-
|
|
18138
|
+
if (idx <= 0) return;
|
|
18139
|
+
const next = TIER_ORDER[idx - 1];
|
|
18140
|
+
if (now - this.healthySince < this.requiredHealthyMs(next)) return;
|
|
18141
|
+
if (!this.canUpgrade(now)) return;
|
|
18142
|
+
this.tier = next;
|
|
18143
|
+
this.mtuReduced = false;
|
|
18144
|
+
this.transcodeEngaged = false;
|
|
18145
|
+
this.opts.onSwitchTier(next);
|
|
18146
|
+
this.commitChange(now);
|
|
18147
|
+
this.healthySince = now;
|
|
17912
18148
|
}
|
|
17913
18149
|
};
|
|
17914
18150
|
//#endregion
|
|
@@ -17933,6 +18169,7 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
|
|
|
17933
18169
|
t: require_dist.literal("scrubTo"),
|
|
17934
18170
|
epoch: require_dist.number()
|
|
17935
18171
|
}),
|
|
18172
|
+
require_dist.object({ t: require_dist.literal("scrubAck") }),
|
|
17936
18173
|
require_dist.object({
|
|
17937
18174
|
t: require_dist.literal("setRate"),
|
|
17938
18175
|
rate: require_dist.number().min(0).max(4)
|
|
@@ -18106,6 +18343,74 @@ var SCRUB_CACHE_MAX = 8;
|
|
|
18106
18343
|
* sustained direction, so a jittery/reversing drag never wastes the slot.
|
|
18107
18344
|
*/
|
|
18108
18345
|
var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
|
|
18346
|
+
/**
|
|
18347
|
+
* Max forward TARGET delta (ms of timeline, between consecutive drag targets)
|
|
18348
|
+
* that SWEEPS predicted frames instead of jumping. A steady forward drag is
|
|
18349
|
+
* playback with a moving target: once a keyframe decoded, the predicted frames
|
|
18350
|
+
* after it decode in sequence at a fraction of an IDR's bytes — that is the
|
|
18351
|
+
* continuous flow. Beyond this window (a fling), or backwards, a jump to an
|
|
18352
|
+
* independently-decodable keyframe is cheaper than the burst; and because a
|
|
18353
|
+
* thin pipe slows the ack-gated ticks down, per-tick deltas GROW under
|
|
18354
|
+
* congestion, so degradation to jump-only is emergent, and visible per push
|
|
18355
|
+
* (`mode` on every scrub-feeder:pushed line).
|
|
18356
|
+
*/
|
|
18357
|
+
var SCRUB_SWEEP_WINDOW_MS = 4e3;
|
|
18358
|
+
/**
|
|
18359
|
+
* Receiver-credit cap for scrub pushes: at most this many keyframes may be
|
|
18360
|
+
* OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
|
|
18361
|
+
* the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
|
|
18362
|
+
* a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
|
|
18363
|
+
* floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
|
|
18364
|
+
* Backpressure is the receiver's: a push spends a credit, a rendered frame
|
|
18365
|
+
* restores one, and with zero credit the newest target PARKS on
|
|
18366
|
+
* `scrubPendingEpochMs` until an ack arrives — a timer never releases work.
|
|
18367
|
+
*/
|
|
18368
|
+
var SCRUB_CREDIT_CAP = 2;
|
|
18369
|
+
/**
|
|
18370
|
+
* How far a `gap` locate may be SNAPPED onto real footage.
|
|
18371
|
+
*
|
|
18372
|
+
* A snap is a CORRECTION — the playhead landed a moment outside coverage and
|
|
18373
|
+
* the operator plainly means the footage beside it — not a SEARCH for wherever
|
|
18374
|
+
* footage happens to exist. The distinction is not cosmetic, because the index
|
|
18375
|
+
* that answers `locate` cannot tell "there is no footage here" apart from "I
|
|
18376
|
+
* have not walked this far back yet": it is rebuilt by walking the archive on
|
|
18377
|
+
* every recorder start, and that walk takes minutes.
|
|
18378
|
+
*
|
|
18379
|
+
* Production 2026-08-02: the recorder had restarted 9 minutes earlier, so
|
|
18380
|
+
* `locate` answered `gap` for the whole timeline with a `nearestEdgeMs` 6
|
|
18381
|
+
* minutes to 23 HOURS away. Unbounded snapping turned every seek and every drag
|
|
18382
|
+
* tick into a segment read against the addon that was busy re-walking, each one
|
|
18383
|
+
* blew the 8 s budget, and both surfaces died. Bounded, all of those answer
|
|
18384
|
+
* `gap` for free — exactly as they did before snapping existed.
|
|
18385
|
+
*
|
|
18386
|
+
* 120 s covers the real cases by a wide margin: an inter-segment crack is
|
|
18387
|
+
* 11–17 ms, and the dead band between two recording bands measured 48 s. Beyond
|
|
18388
|
+
* that the server is answering a question the viewer did not ask; the viewer
|
|
18389
|
+
* holds the coverage ranges and can offer "jump to nearest footage" as an
|
|
18390
|
+
* action instead.
|
|
18391
|
+
*/
|
|
18392
|
+
var SNAP_MAX_DISTANCE_MS = 12e4;
|
|
18393
|
+
/**
|
|
18394
|
+
* Decide where a `gap` locate should snap: forward to the next footage edge,
|
|
18395
|
+
* else backward onto the last ms of the footage behind (`prevEndMs − 1`, which
|
|
18396
|
+
* also hops the 11–17 ms inter-segment cracks). Rejects a candidate further than
|
|
18397
|
+
* {@link SNAP_MAX_DISTANCE_MS} so a partially-walked index cannot teleport the
|
|
18398
|
+
* cursor — and so rejecting costs NO segment I/O at all.
|
|
18399
|
+
*/
|
|
18400
|
+
function resolveSnapTarget(loc, requestedMs) {
|
|
18401
|
+
const snapMs = loc.nearestEdgeMs ?? (typeof loc.prevEndMs === "number" ? loc.prevEndMs - 1 : null);
|
|
18402
|
+
if (snapMs === null) return { kind: "none" };
|
|
18403
|
+
const distanceMs = Math.abs(snapMs - requestedMs);
|
|
18404
|
+
if (distanceMs > SNAP_MAX_DISTANCE_MS) return {
|
|
18405
|
+
kind: "too-far",
|
|
18406
|
+
snapMs,
|
|
18407
|
+
distanceMs
|
|
18408
|
+
};
|
|
18409
|
+
return {
|
|
18410
|
+
kind: "snap",
|
|
18411
|
+
snapMs
|
|
18412
|
+
};
|
|
18413
|
+
}
|
|
18109
18414
|
var RecordedFeeder = class {
|
|
18110
18415
|
deps;
|
|
18111
18416
|
clock = new MonotonicClock();
|
|
@@ -18148,6 +18453,16 @@ var RecordedFeeder = class {
|
|
|
18148
18453
|
*/
|
|
18149
18454
|
scrubbing = false;
|
|
18150
18455
|
scrubToken = 0;
|
|
18456
|
+
/** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
|
|
18457
|
+
scrubCredit = SCRUB_CREDIT_CAP;
|
|
18458
|
+
/**
|
|
18459
|
+
* Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
|
|
18460
|
+
* `1`; a sweep enqueues however many predicted frames it put on the wire. Each
|
|
18461
|
+
* `scrubAck` settles one frame off the head, and the credit that push spent
|
|
18462
|
+
* comes back only when the head reaches zero — so a sweep cannot be refunded
|
|
18463
|
+
* once per frame for the single credit it cost.
|
|
18464
|
+
*/
|
|
18465
|
+
scrubOutstanding = [];
|
|
18151
18466
|
/** The newest un-processed drag target (null when the tick cycle has drained). */
|
|
18152
18467
|
scrubPendingEpochMs = null;
|
|
18153
18468
|
/**
|
|
@@ -18178,6 +18493,15 @@ var RecordedFeeder = class {
|
|
|
18178
18493
|
* sign of `(newTarget − this)` is the drag direction that steers prefetch.
|
|
18179
18494
|
*/
|
|
18180
18495
|
scrubPrevTarget = null;
|
|
18496
|
+
/** The last AU actually pushed this drag — the sweep-continuity cursor
|
|
18497
|
+
* (which fragment, and how deep into its AU list). Null until a jump lands. */
|
|
18498
|
+
scrubCursor = null;
|
|
18499
|
+
/** Per-drag mode accounting for the `scrub-feeder:drag-end` summary. */
|
|
18500
|
+
scrubJumps = 0;
|
|
18501
|
+
scrubSweeps = 0;
|
|
18502
|
+
scrubAusPushed = 0;
|
|
18503
|
+
/** Once-per-drag guard: a dead-zone drag ticks fast; one line says it all. */
|
|
18504
|
+
scrubNoFootageLogged = false;
|
|
18181
18505
|
/**
|
|
18182
18506
|
* Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
|
|
18183
18507
|
* − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
|
|
@@ -18215,9 +18539,19 @@ var RecordedFeeder = class {
|
|
|
18215
18539
|
async seek(epochMs) {
|
|
18216
18540
|
const token = ++this.seekToken;
|
|
18217
18541
|
if (this.scrubbing) {
|
|
18542
|
+
this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
18543
|
+
jumps: this.scrubJumps,
|
|
18544
|
+
sweeps: this.scrubSweeps,
|
|
18545
|
+
aus: this.scrubAusPushed
|
|
18546
|
+
});
|
|
18218
18547
|
this.scrubbing = false;
|
|
18219
18548
|
this.scrubToken++;
|
|
18220
18549
|
this.scrubSegment = null;
|
|
18550
|
+
this.scrubCursor = null;
|
|
18551
|
+
this.scrubJumps = 0;
|
|
18552
|
+
this.scrubSweeps = 0;
|
|
18553
|
+
this.scrubAusPushed = 0;
|
|
18554
|
+
this.scrubOutstanding = [];
|
|
18221
18555
|
this.scrubCache.clear();
|
|
18222
18556
|
this.scrubPrevTarget = null;
|
|
18223
18557
|
this.scrubDirRun = 0;
|
|
@@ -18228,29 +18562,67 @@ var RecordedFeeder = class {
|
|
|
18228
18562
|
}
|
|
18229
18563
|
this.advanceToken++;
|
|
18230
18564
|
this.inFlightSeekEpochMs = epochMs;
|
|
18565
|
+
let snapInfo = null;
|
|
18231
18566
|
try {
|
|
18232
18567
|
this.deps.onState("loading");
|
|
18233
18568
|
if (this.clockHasAdvanced) {
|
|
18234
18569
|
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18235
18570
|
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18236
18571
|
}
|
|
18237
|
-
|
|
18572
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
18238
18573
|
deviceId: this.deps.deviceId,
|
|
18239
18574
|
profile: this.deps.profile,
|
|
18240
18575
|
epochMs
|
|
18241
18576
|
}));
|
|
18242
18577
|
if (this.disposed || token !== this.seekToken) return;
|
|
18578
|
+
let landMs = epochMs;
|
|
18243
18579
|
if (loc.kind === "gap") {
|
|
18244
|
-
|
|
18245
|
-
|
|
18246
|
-
|
|
18247
|
-
|
|
18248
|
-
|
|
18580
|
+
const decision = resolveSnapTarget(loc, epochMs);
|
|
18581
|
+
if (decision.kind !== "snap") {
|
|
18582
|
+
if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
|
|
18583
|
+
requestedMs: epochMs,
|
|
18584
|
+
nearestMs: decision.snapMs,
|
|
18585
|
+
distanceMs: decision.distanceMs
|
|
18586
|
+
});
|
|
18587
|
+
else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
|
|
18588
|
+
this.currentSegment = null;
|
|
18589
|
+
this.lastFedVideoIdx = -1;
|
|
18590
|
+
this.stopPositionReports();
|
|
18591
|
+
this.deps.onState("gap");
|
|
18592
|
+
return;
|
|
18593
|
+
}
|
|
18594
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
18595
|
+
deviceId: this.deps.deviceId,
|
|
18596
|
+
profile: this.deps.profile,
|
|
18597
|
+
epochMs: decision.snapMs
|
|
18598
|
+
}));
|
|
18599
|
+
if (this.disposed || token !== this.seekToken) return;
|
|
18600
|
+
if (snapped.kind !== "segment") {
|
|
18601
|
+
this.deps.onLog?.("recorded:seek-no-footage", {
|
|
18602
|
+
requestedMs: epochMs,
|
|
18603
|
+
snappedMs: decision.snapMs
|
|
18604
|
+
});
|
|
18605
|
+
this.currentSegment = null;
|
|
18606
|
+
this.lastFedVideoIdx = -1;
|
|
18607
|
+
this.stopPositionReports();
|
|
18608
|
+
this.deps.onState("gap");
|
|
18609
|
+
return;
|
|
18610
|
+
}
|
|
18611
|
+
this.deps.onLog?.("recorded:seek-snapped", {
|
|
18612
|
+
requestedMs: epochMs,
|
|
18613
|
+
snappedMs: decision.snapMs
|
|
18614
|
+
});
|
|
18615
|
+
loc = snapped;
|
|
18616
|
+
landMs = decision.snapMs;
|
|
18617
|
+
snapInfo = {
|
|
18618
|
+
requestedMs: epochMs,
|
|
18619
|
+
snappedMs: decision.snapMs
|
|
18620
|
+
};
|
|
18249
18621
|
}
|
|
18250
18622
|
this.cursorMs = loc.startMs;
|
|
18251
18623
|
const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
|
|
18252
18624
|
if (this.disposed || token !== this.seekToken || loaded === null) return;
|
|
18253
|
-
this.feedPrefix(loc, loaded.video,
|
|
18625
|
+
this.feedPrefix(loc, loaded.video, landMs);
|
|
18254
18626
|
if (this.rate === 0) {
|
|
18255
18627
|
this.deps.onState("paused");
|
|
18256
18628
|
this.deps.onPosition(this.cursorMs);
|
|
@@ -18270,9 +18642,20 @@ var RecordedFeeder = class {
|
|
|
18270
18642
|
},
|
|
18271
18643
|
resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
|
|
18272
18644
|
});
|
|
18273
|
-
} catch {
|
|
18645
|
+
} catch (err) {
|
|
18274
18646
|
if (!this.disposed && token === this.seekToken) {
|
|
18275
18647
|
this.stopPositionReports();
|
|
18648
|
+
if (snapInfo !== null) {
|
|
18649
|
+
this.deps.onLog?.("recorded:seek-snap-load-failed", {
|
|
18650
|
+
requestedMs: snapInfo.requestedMs,
|
|
18651
|
+
snappedMs: snapInfo.snappedMs,
|
|
18652
|
+
error: require_dist.errMsg(err)
|
|
18653
|
+
});
|
|
18654
|
+
this.currentSegment = null;
|
|
18655
|
+
this.lastFedVideoIdx = -1;
|
|
18656
|
+
this.deps.onState("gap");
|
|
18657
|
+
return;
|
|
18658
|
+
}
|
|
18276
18659
|
this.deps.onState("error");
|
|
18277
18660
|
}
|
|
18278
18661
|
} finally {
|
|
@@ -18327,8 +18710,15 @@ var RecordedFeeder = class {
|
|
|
18327
18710
|
this.scrubToken++;
|
|
18328
18711
|
this.scrubPendingEpochMs = null;
|
|
18329
18712
|
this.scrubSegment = null;
|
|
18713
|
+
this.scrubCredit = SCRUB_CREDIT_CAP;
|
|
18330
18714
|
this.scrubPrevTarget = null;
|
|
18331
18715
|
this.scrubDirRun = 0;
|
|
18716
|
+
this.scrubCursor = null;
|
|
18717
|
+
this.scrubJumps = 0;
|
|
18718
|
+
this.scrubSweeps = 0;
|
|
18719
|
+
this.scrubAusPushed = 0;
|
|
18720
|
+
this.scrubNoFootageLogged = false;
|
|
18721
|
+
this.scrubOutstanding = [];
|
|
18332
18722
|
if (this.scrubTimer) {
|
|
18333
18723
|
clearTimeout(this.scrubTimer);
|
|
18334
18724
|
this.scrubTimer = null;
|
|
@@ -18348,6 +18738,24 @@ var RecordedFeeder = class {
|
|
|
18348
18738
|
if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
18349
18739
|
}
|
|
18350
18740
|
/**
|
|
18741
|
+
* Receiver credit: the viewer RENDERED a scrub frame. Restores one push
|
|
18742
|
+
* credit (capped — over-acking never banks a burst) and, when a target sat
|
|
18743
|
+
* parked on an exhausted budget, resumes the tick. This — not any timer — is
|
|
18744
|
+
* what paces scrub pushes to what the link actually delivers.
|
|
18745
|
+
*/
|
|
18746
|
+
scrubAck() {
|
|
18747
|
+
if (this.disposed) return;
|
|
18748
|
+
const head = this.scrubOutstanding[0];
|
|
18749
|
+
if (head === void 0) return;
|
|
18750
|
+
if (head > 1) {
|
|
18751
|
+
this.scrubOutstanding[0] = head - 1;
|
|
18752
|
+
return;
|
|
18753
|
+
}
|
|
18754
|
+
this.scrubOutstanding.shift();
|
|
18755
|
+
this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
|
|
18756
|
+
if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
18757
|
+
}
|
|
18758
|
+
/**
|
|
18351
18759
|
* One coalesced feeder-scrub push. Captures the newest pending target + the
|
|
18352
18760
|
* current mode generation, resolves the covering fragment (reusing the memo'd
|
|
18353
18761
|
* one when the target stays inside it), picks the nearest keyframe ≤ the
|
|
@@ -18358,25 +18766,68 @@ var RecordedFeeder = class {
|
|
|
18358
18766
|
*/
|
|
18359
18767
|
async scrubTick() {
|
|
18360
18768
|
const gen = this.scrubToken;
|
|
18769
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) {
|
|
18770
|
+
this.scrubTimer = null;
|
|
18771
|
+
return;
|
|
18772
|
+
}
|
|
18773
|
+
if (this.scrubCredit <= 0) {
|
|
18774
|
+
this.scrubTimer = null;
|
|
18775
|
+
this.deps.onLog?.("scrub-feeder:parked", { pendingEpochMs: this.scrubPendingEpochMs });
|
|
18776
|
+
return;
|
|
18777
|
+
}
|
|
18361
18778
|
const target = this.scrubPendingEpochMs;
|
|
18362
18779
|
this.scrubPendingEpochMs = null;
|
|
18363
|
-
if (
|
|
18780
|
+
if (target === null) {
|
|
18364
18781
|
this.scrubTimer = null;
|
|
18365
18782
|
return;
|
|
18366
18783
|
}
|
|
18367
18784
|
try {
|
|
18368
|
-
|
|
18785
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
18369
18786
|
deviceId: this.deps.deviceId,
|
|
18370
18787
|
profile: this.deps.profile,
|
|
18371
18788
|
epochMs: target
|
|
18372
18789
|
}));
|
|
18373
18790
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18374
|
-
|
|
18791
|
+
let effTarget = target;
|
|
18792
|
+
if (loc.kind === "gap") {
|
|
18793
|
+
const decision = resolveSnapTarget(loc, target);
|
|
18794
|
+
if (decision.kind !== "snap") {
|
|
18795
|
+
if (!this.scrubNoFootageLogged) {
|
|
18796
|
+
this.scrubNoFootageLogged = true;
|
|
18797
|
+
if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
|
|
18798
|
+
epochMs: target,
|
|
18799
|
+
nearestMs: decision.snapMs,
|
|
18800
|
+
distanceMs: decision.distanceMs
|
|
18801
|
+
});
|
|
18802
|
+
else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
18803
|
+
}
|
|
18804
|
+
return;
|
|
18805
|
+
}
|
|
18806
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
18807
|
+
deviceId: this.deps.deviceId,
|
|
18808
|
+
profile: this.deps.profile,
|
|
18809
|
+
epochMs: decision.snapMs
|
|
18810
|
+
}));
|
|
18811
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18812
|
+
if (snapped.kind !== "segment") {
|
|
18813
|
+
if (!this.scrubNoFootageLogged) {
|
|
18814
|
+
this.scrubNoFootageLogged = true;
|
|
18815
|
+
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
18816
|
+
}
|
|
18817
|
+
return;
|
|
18818
|
+
}
|
|
18819
|
+
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
18820
|
+
requestedMs: target,
|
|
18821
|
+
snappedMs: decision.snapMs
|
|
18822
|
+
});
|
|
18823
|
+
loc = snapped;
|
|
18824
|
+
effTarget = decision.snapMs;
|
|
18825
|
+
}
|
|
18375
18826
|
let seg = this.scrubSegment;
|
|
18376
18827
|
if (!seg || seg.startMs !== loc.startMs) {
|
|
18377
18828
|
seg = this.scrubCacheGet(loc.startMs);
|
|
18378
18829
|
if (!seg) {
|
|
18379
|
-
const loaded = await this.loadAus(loc.startMs, false
|
|
18830
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
18380
18831
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
18381
18832
|
seg = {
|
|
18382
18833
|
startMs: loc.startMs,
|
|
@@ -18387,26 +18838,80 @@ var RecordedFeeder = class {
|
|
|
18387
18838
|
}
|
|
18388
18839
|
this.scrubSegment = seg;
|
|
18389
18840
|
}
|
|
18390
|
-
const
|
|
18391
|
-
|
|
18392
|
-
|
|
18393
|
-
|
|
18841
|
+
const prevTarget = this.scrubPrevTarget;
|
|
18842
|
+
const cursor = this.scrubCursor;
|
|
18843
|
+
const targetIdx = this.pickScrubIndex(seg, effTarget, false);
|
|
18844
|
+
const forwardDelta = prevTarget === null ? null : effTarget - prevTarget;
|
|
18845
|
+
const withinWindow = forwardDelta !== null && forwardDelta > 0 && forwardDelta <= SCRUB_SWEEP_WINDOW_MS;
|
|
18846
|
+
const sameFragment = cursor !== null && cursor.fragStartMs === seg.startMs;
|
|
18847
|
+
const adjacentForward = cursor !== null && seg.startMs === cursor.fragStartMs + cursor.fragDurMs;
|
|
18848
|
+
let aus;
|
|
18849
|
+
let firstIdx;
|
|
18850
|
+
let mode;
|
|
18851
|
+
let crossesFragment = false;
|
|
18852
|
+
if (withinWindow && sameFragment && cursor !== null && targetIdx > cursor.idx) {
|
|
18853
|
+
mode = "sweep";
|
|
18854
|
+
firstIdx = cursor.idx + 1;
|
|
18855
|
+
aus = seg.aus.slice(firstIdx, targetIdx + 1);
|
|
18856
|
+
} else if (withinWindow && adjacentForward) {
|
|
18857
|
+
mode = "sweep";
|
|
18858
|
+
firstIdx = 0;
|
|
18859
|
+
aus = seg.aus.slice(0, targetIdx + 1);
|
|
18860
|
+
crossesFragment = true;
|
|
18861
|
+
} else {
|
|
18862
|
+
mode = "jump";
|
|
18863
|
+
firstIdx = this.pickScrubIndex(seg, effTarget, true);
|
|
18864
|
+
const kf = seg.aus[firstIdx];
|
|
18865
|
+
aus = kf === void 0 ? [] : [kf];
|
|
18866
|
+
}
|
|
18867
|
+
const lastAu = aus[aus.length - 1];
|
|
18868
|
+
if (lastAu === void 0) {
|
|
18869
|
+
this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
|
|
18870
|
+
this.scrubPrevTarget = effTarget;
|
|
18871
|
+
return;
|
|
18872
|
+
}
|
|
18873
|
+
if (mode === "jump" && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18874
|
+
else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
|
|
18875
|
+
for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
18876
|
+
this.scrubOutstanding.push(aus.length);
|
|
18877
|
+
this.scrubCredit -= 1;
|
|
18394
18878
|
this.clockHasAdvanced = true;
|
|
18395
|
-
|
|
18396
|
-
this.
|
|
18397
|
-
|
|
18398
|
-
|
|
18879
|
+
if (mode === "jump") this.scrubJumps += 1;
|
|
18880
|
+
else this.scrubSweeps += 1;
|
|
18881
|
+
this.scrubAusPushed += aus.length;
|
|
18882
|
+
this.scrubCursor = {
|
|
18883
|
+
fragStartMs: seg.startMs,
|
|
18884
|
+
fragDurMs: seg.durMs,
|
|
18885
|
+
idx: firstIdx + aus.length - 1
|
|
18886
|
+
};
|
|
18887
|
+
this.deps.onLog?.("scrub-feeder:pushed", {
|
|
18888
|
+
epochMs: effTarget,
|
|
18889
|
+
mode,
|
|
18890
|
+
aus: aus.length,
|
|
18891
|
+
codec: lastAu.codec,
|
|
18892
|
+
bytes: lastAu.data.length,
|
|
18893
|
+
creditLeft: this.scrubCredit
|
|
18894
|
+
});
|
|
18895
|
+
this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
|
|
18896
|
+
this.maybeScrubPrefetch(seg, effTarget, gen);
|
|
18897
|
+
} catch (err) {
|
|
18898
|
+
this.deps.onLog?.("scrub-feeder:tick-failed", {
|
|
18899
|
+
epochMs: target,
|
|
18900
|
+
error: err instanceof Error ? err.message : String(err)
|
|
18901
|
+
});
|
|
18902
|
+
} finally {
|
|
18903
|
+
if (gen !== this.scrubToken) {} else if (!this.disposed && this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubCredit > 0) this.scrubTimer = setTimeout(() => void this.scrubTick(), SCRUB_COALESCE_MS);
|
|
18399
18904
|
else this.scrubTimer = null;
|
|
18400
18905
|
}
|
|
18401
18906
|
}
|
|
18402
18907
|
/**
|
|
18403
|
-
* Index of the nearest
|
|
18908
|
+
* Index of the nearest AU at-or-before the drag target within `seg`
|
|
18404
18909
|
* (default 0 — the fragment's params-fused IDR, which the demux contract
|
|
18405
|
-
* guarantees).
|
|
18406
|
-
*
|
|
18407
|
-
*
|
|
18910
|
+
* guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
|
|
18911
|
+
* mode, whose pushed frame must be standalone-decodable; sweep mode picks
|
|
18912
|
+
* ANY frame, which is what makes the flow frame-granular.
|
|
18408
18913
|
*/
|
|
18409
|
-
|
|
18914
|
+
pickScrubIndex(seg, target, keyframesOnly) {
|
|
18410
18915
|
const first = seg.aus[0];
|
|
18411
18916
|
if (!first) return 0;
|
|
18412
18917
|
const offsetMs = target - seg.startMs;
|
|
@@ -18415,7 +18920,7 @@ var RecordedFeeder = class {
|
|
|
18415
18920
|
const au = seg.aus[i];
|
|
18416
18921
|
if (!au) continue;
|
|
18417
18922
|
if (au.ptsMs - first.ptsMs > offsetMs) break;
|
|
18418
|
-
if (au.keyframe) idx = i;
|
|
18923
|
+
if (!keyframesOnly || au.keyframe) idx = i;
|
|
18419
18924
|
}
|
|
18420
18925
|
return idx;
|
|
18421
18926
|
}
|
|
@@ -18486,7 +18991,7 @@ var RecordedFeeder = class {
|
|
|
18486
18991
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18487
18992
|
if (loc.kind !== "segment") return;
|
|
18488
18993
|
if (this.scrubCache.has(loc.startMs)) return;
|
|
18489
|
-
const loaded = await this.loadAus(loc.startMs, false
|
|
18994
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
18490
18995
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
18491
18996
|
this.scrubCachePut({
|
|
18492
18997
|
startMs: loc.startMs,
|
|
@@ -18836,10 +19341,8 @@ var RecordedFeeder = class {
|
|
|
18836
19341
|
* access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
|
|
18837
19342
|
* (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
|
|
18838
19343
|
* never blocks video). `wantAudio: false` skips the audio transcode entirely
|
|
18839
|
-
* (paused scrubs would throw it away).
|
|
18840
|
-
|
|
18841
|
-
* when absent — flag-off byte-identity). */
|
|
18842
|
-
async loadAus(startMs, wantAudio = true, useScrubDemux = false) {
|
|
19344
|
+
* (paused scrubs would throw it away). */
|
|
19345
|
+
async loadAus(startMs, wantAudio = true) {
|
|
18843
19346
|
const token = this.seekToken;
|
|
18844
19347
|
const bytes = await this.withTimeout(this.deps.readBytes({
|
|
18845
19348
|
deviceId: this.deps.deviceId,
|
|
@@ -18847,8 +19350,7 @@ var RecordedFeeder = class {
|
|
|
18847
19350
|
startMs
|
|
18848
19351
|
}));
|
|
18849
19352
|
if (this.disposed || token !== this.seekToken) return null;
|
|
18850
|
-
const
|
|
18851
|
-
const video = await this.withTimeout(demux(bytes));
|
|
19353
|
+
const video = await this.withTimeout(this.deps.demux(bytes));
|
|
18852
19354
|
if (this.disposed || token !== this.seekToken) return null;
|
|
18853
19355
|
let audio = [];
|
|
18854
19356
|
if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
|
|
@@ -18894,6 +19396,11 @@ var RecordedFeeder = class {
|
|
|
18894
19396
|
}
|
|
18895
19397
|
/** Stop everything: invalidate in-flight work and tear down the position timer. */
|
|
18896
19398
|
dispose() {
|
|
19399
|
+
if (this.scrubbing) this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
19400
|
+
jumps: this.scrubJumps,
|
|
19401
|
+
sweeps: this.scrubSweeps,
|
|
19402
|
+
aus: this.scrubAusPushed
|
|
19403
|
+
});
|
|
18897
19404
|
this.disposed = true;
|
|
18898
19405
|
this.seekToken++;
|
|
18899
19406
|
this.advanceToken++;
|
|
@@ -18903,6 +19410,12 @@ var RecordedFeeder = class {
|
|
|
18903
19410
|
this.scrubCache.clear();
|
|
18904
19411
|
this.scrubPrevTarget = null;
|
|
18905
19412
|
this.scrubDirRun = 0;
|
|
19413
|
+
this.scrubCursor = null;
|
|
19414
|
+
this.scrubJumps = 0;
|
|
19415
|
+
this.scrubSweeps = 0;
|
|
19416
|
+
this.scrubAusPushed = 0;
|
|
19417
|
+
this.scrubNoFootageLogged = false;
|
|
19418
|
+
this.scrubOutstanding = [];
|
|
18906
19419
|
if (this.scrubTimer) {
|
|
18907
19420
|
clearTimeout(this.scrubTimer);
|
|
18908
19421
|
this.scrubTimer = null;
|
|
@@ -18930,6 +19443,16 @@ var TimelineSession = class {
|
|
|
18930
19443
|
this.deps = deps;
|
|
18931
19444
|
deps.session.setLiveFeedGate(false);
|
|
18932
19445
|
}
|
|
19446
|
+
/**
|
|
19447
|
+
* Which source the track is currently carrying. Read by the broker's
|
|
19448
|
+
* adaptive ladder, which must NOT act while the pointer is off live: a tier
|
|
19449
|
+
* switch is a close-and-recreate re-offer, and that disposes this
|
|
19450
|
+
* coordinator — the viewer silently lands back on live at the wrong moment
|
|
19451
|
+
* with no code path to restore the pointer.
|
|
19452
|
+
*/
|
|
19453
|
+
get currentMode() {
|
|
19454
|
+
return this.mode;
|
|
19455
|
+
}
|
|
18933
19456
|
async handleControl(msg) {
|
|
18934
19457
|
switch (msg.t) {
|
|
18935
19458
|
case "playRecorded":
|
|
@@ -18946,6 +19469,9 @@ var TimelineSession = class {
|
|
|
18946
19469
|
case "scrubTo":
|
|
18947
19470
|
if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
|
|
18948
19471
|
break;
|
|
19472
|
+
case "scrubAck":
|
|
19473
|
+
if (this.mode === "recorded") this.feeder?.scrubAck();
|
|
19474
|
+
break;
|
|
18949
19475
|
case "setRate":
|
|
18950
19476
|
this.feeder?.setRate(msg.rate);
|
|
18951
19477
|
break;
|
|
@@ -18953,6 +19479,7 @@ var TimelineSession = class {
|
|
|
18953
19479
|
if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
|
|
18954
19480
|
break;
|
|
18955
19481
|
case "setProfile":
|
|
19482
|
+
if (msg.profile === this.profile) break;
|
|
18956
19483
|
this.profile = msg.profile;
|
|
18957
19484
|
if (this.mode === "recorded") this.enterRecorded();
|
|
18958
19485
|
break;
|
|
@@ -25670,25 +26197,6 @@ async function extractSamples(segment, segmentStartMs) {
|
|
|
25670
26197
|
samples: collected
|
|
25671
26198
|
} : null;
|
|
25672
26199
|
}
|
|
25673
|
-
/**
|
|
25674
|
-
* Demux a recorded LOW `.m4s` segment and return its sync samples plus the
|
|
25675
|
-
* WebCodecs decoder config, or null if the segment carries no video / no
|
|
25676
|
-
* decodable keyframe. `segmentStartMs` anchors keyframe timestamps to absolute
|
|
25677
|
-
* epoch ms; the first sample's cts is the zero reference.
|
|
25678
|
-
*/
|
|
25679
|
-
async function extractKeyframes(segment, segmentStartMs) {
|
|
25680
|
-
const extracted = await extractSamples(segment, segmentStartMs);
|
|
25681
|
-
if (!extracted) return null;
|
|
25682
|
-
const keyframes = extracted.samples.filter((s) => s.isSync).map((s) => ({
|
|
25683
|
-
ts: s.ts,
|
|
25684
|
-
data: s.data
|
|
25685
|
-
}));
|
|
25686
|
-
return keyframes.length > 0 ? {
|
|
25687
|
-
codec: extracted.codec,
|
|
25688
|
-
description: extracted.description,
|
|
25689
|
-
keyframes
|
|
25690
|
-
} : null;
|
|
25691
|
-
}
|
|
25692
26200
|
//#endregion
|
|
25693
26201
|
//#region src/stream-broker/recorded/avcc-annexb.ts
|
|
25694
26202
|
/**
|
|
@@ -26192,33 +26700,6 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
|
|
|
26192
26700
|
"pipe:1"
|
|
26193
26701
|
];
|
|
26194
26702
|
/**
|
|
26195
|
-
* ffmpeg arguments for the SCRUB keyframe-only demux: same crash-isolated
|
|
26196
|
-
* `-c:v copy` mpegts remux as {@link SEGMENT_DEMUX_FFMPEG_ARGS}, but `-frames:v 1`
|
|
26197
|
-
* stops after the FIRST output access unit — a recorded fragment is a single
|
|
26198
|
-
* IDR-led GOP, so frame 1 is the params-fused keyframe the scrub sub-mode needs
|
|
26199
|
-
* (it never renders a delta frame). This reliably yields exactly the IDR with
|
|
26200
|
-
* its VPS/SPS/PPS fused, whereas input-side `-discard nokey` is a no-op over the
|
|
26201
|
-
* `pipe:0` fragmented-mp4 demuxer (empirically kept 39/40 packets). Exported for
|
|
26202
|
-
* the spec.
|
|
26203
|
-
*/
|
|
26204
|
-
var SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS = [
|
|
26205
|
-
"-hide_banner",
|
|
26206
|
-
"-loglevel",
|
|
26207
|
-
"error",
|
|
26208
|
-
"-i",
|
|
26209
|
-
"pipe:0",
|
|
26210
|
-
"-map",
|
|
26211
|
-
"0:v:0",
|
|
26212
|
-
"-frames:v",
|
|
26213
|
-
"1",
|
|
26214
|
-
"-c:v",
|
|
26215
|
-
"copy",
|
|
26216
|
-
"-an",
|
|
26217
|
-
"-f",
|
|
26218
|
-
"mpegts",
|
|
26219
|
-
"pipe:1"
|
|
26220
|
-
];
|
|
26221
|
-
/**
|
|
26222
26703
|
* Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
|
|
26223
26704
|
* access units.
|
|
26224
26705
|
*
|
|
@@ -26244,113 +26725,35 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
|
|
|
26244
26725
|
codec
|
|
26245
26726
|
}));
|
|
26246
26727
|
}
|
|
26247
|
-
/**
|
|
26248
|
-
* Keyframe-only demux for the feeder-scrub sub-mode: remux with `-frames:v 1`
|
|
26249
|
-
* (single-GOP fragment ⇒ the one IDR) and return ONLY keyframe access units.
|
|
26250
|
-
* The scrub path never renders delta frames — it always pushes the nearest
|
|
26251
|
-
* keyframe ≤ the drag target — so extracting only the IDR keeps the per-session
|
|
26252
|
-
* scrub fragment cache tiny (one AU/fragment instead of the full ~40) with no
|
|
26253
|
-
* loss of scrub fidelity.
|
|
26254
|
-
*
|
|
26255
|
-
* Wall time per fragment is dominated by the ffmpeg subprocess spawn, so this is
|
|
26256
|
-
* NOT meaningfully faster per fragment than {@link demuxSegmentToAnnexB}; the
|
|
26257
|
-
* frequency win comes from the feeder's fragment cache + direction-aware
|
|
26258
|
-
* prefetch (which eliminate / overlap the demux), for which this compact
|
|
26259
|
-
* keyframe-only payload is the cache unit.
|
|
26260
|
-
*
|
|
26261
|
-
* Defensive parse-side filter: keeps `keyframe` AUs, falling back to the first
|
|
26262
|
-
* AU (the demux contract's params-fused IDR) if none were flagged, so the result
|
|
26263
|
-
* is keyframes-only yet never empty for a valid fragment.
|
|
26264
|
-
*
|
|
26265
|
-
* @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
|
|
26266
|
-
* @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
|
|
26267
|
-
*/
|
|
26268
|
-
async function demuxSegmentKeyframesToAnnexB(bytes, ffmpegPath = "ffmpeg") {
|
|
26269
|
-
const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
|
|
26270
|
-
ffmpegPath,
|
|
26271
|
-
args: SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS,
|
|
26272
|
-
input: bytes
|
|
26273
|
-
}));
|
|
26274
|
-
const mapped = accessUnits.map((au) => ({
|
|
26275
|
-
data: au.data,
|
|
26276
|
-
ptsMs: au.ptsMs,
|
|
26277
|
-
keyframe: au.keyframe,
|
|
26278
|
-
codec
|
|
26279
|
-
}));
|
|
26280
|
-
const keyframes = mapped.filter((au) => au.keyframe);
|
|
26281
|
-
return keyframes.length > 0 ? keyframes : mapped.slice(0, 1);
|
|
26282
|
-
}
|
|
26283
26728
|
//#endregion
|
|
26284
26729
|
//#region src/stream-broker/recorded/segment-demux-js.ts
|
|
26285
26730
|
/**
|
|
26286
26731
|
* segment-demux-js — PURE-JS in-process demux for recorded playback.
|
|
26287
26732
|
*
|
|
26288
|
-
*
|
|
26289
|
-
*
|
|
26290
|
-
*
|
|
26291
|
-
*
|
|
26292
|
-
* (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
|
|
26733
|
+
* {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} — FULL playback
|
|
26734
|
+
* (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
|
|
26735
|
+
* Both playback AND scrub demux through this since sweep mode (the scrub train
|
|
26736
|
+
* pushes predicted frames, so the whole GOP is needed either way).
|
|
26293
26737
|
*
|
|
26294
|
-
* Replaces the per-fragment ffmpeg spawn (
|
|
26295
|
-
*
|
|
26738
|
+
* Replaces the per-fragment ffmpeg spawn (a ~140 ms subprocess floor) with an
|
|
26739
|
+
* in-process mp4box parse. A recorded `.m4s`
|
|
26296
26740
|
* is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
|
|
26297
26741
|
* carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
|
|
26298
26742
|
* sample(s) are all in the bytes we already hold — no init segment to fetch.
|
|
26299
26743
|
*
|
|
26300
|
-
* Flow:
|
|
26301
|
-
*
|
|
26302
|
-
*
|
|
26303
|
-
* {@link buildKeyframeAnnexB}
|
|
26304
|
-
*
|
|
26305
|
-
*
|
|
26306
|
-
* `
|
|
26307
|
-
* sample — the only thing the feeder reads, via `au.ptsMs - first.ptsMs`).
|
|
26308
|
-
*
|
|
26309
|
-
* Anchoring `extractKeyframes(bytes, 0)` makes the first sync sample's `ts` = 0,
|
|
26310
|
-
* so `ptsMs` is a plain segment-relative offset in ms, exactly like the ffmpeg
|
|
26311
|
-
* demux's PES-derived deltas — `clampIntoSegment`/position reporting are
|
|
26312
|
-
* unaffected.
|
|
26744
|
+
* Flow: {@link extractSamples} (the proven mp4box demux the recorder scrub
|
|
26745
|
+
* module already ships — box parsing is NOT duplicated here) yields the codec
|
|
26746
|
+
* `description` + length-prefixed samples; sync samples are fused with their
|
|
26747
|
+
* parameter sets ({@link buildKeyframeAnnexB}), delta samples converted plain
|
|
26748
|
+
* ({@link avccSampleToAnnexB}). `ptsMs` is segment-relative (anchored to 0 at
|
|
26749
|
+
* the first sample), exactly like the ffmpeg demux's PES-derived deltas —
|
|
26750
|
+
* `clampIntoSegment`/position reporting are unaffected.
|
|
26313
26751
|
*/
|
|
26314
26752
|
/** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
|
|
26315
26753
|
function isHevcCodec(codec) {
|
|
26316
26754
|
return /^h[ev][vc]1\./i.test(codec);
|
|
26317
26755
|
}
|
|
26318
26756
|
/**
|
|
26319
|
-
* Pure-JS keyframe-only demux of one self-contained `.m4s` fragment. Returns the
|
|
26320
|
-
* fragment's sync-sample access unit(s) as params-fused Annex-B, or `null` if
|
|
26321
|
-
* mp4box found no video / no decodable keyframe (caller falls back to ffmpeg).
|
|
26322
|
-
* Throws only on a genuinely malformed decoder config / sample — the wrapper
|
|
26323
|
-
* ({@link demuxSegmentKeyframesPreferJs}) turns that into an ffmpeg fallback.
|
|
26324
|
-
*/
|
|
26325
|
-
async function demuxSegmentKeyframesJs(bytes) {
|
|
26326
|
-
const extracted = await extractKeyframes(bytes, 0);
|
|
26327
|
-
if (!extracted || extracted.keyframes.length === 0) return null;
|
|
26328
|
-
const isHevc = isHevcCodec(extracted.codec);
|
|
26329
|
-
const codec = isHevc ? "H265" : "H264";
|
|
26330
|
-
return extracted.keyframes.slice(0, 1).map((k) => ({
|
|
26331
|
-
data: buildKeyframeAnnexB(k.data, extracted.description, isHevc),
|
|
26332
|
-
ptsMs: k.ts,
|
|
26333
|
-
keyframe: true,
|
|
26334
|
-
codec
|
|
26335
|
-
}));
|
|
26336
|
-
}
|
|
26337
|
-
/**
|
|
26338
|
-
* Feeder-scrub demux: try the pure-JS keyframe extractor first, fall back to the
|
|
26339
|
-
* ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB}) if the JS parse yields no
|
|
26340
|
-
* keyframe or throws on malformed bytes. Preserves the exact {@link AccessUnit}
|
|
26341
|
-
* contract either way, so it is a drop-in for `RecordedFeederDeps.demuxScrub`.
|
|
26342
|
-
*/
|
|
26343
|
-
async function demuxSegmentKeyframesPreferJs(bytes, opts = {}) {
|
|
26344
|
-
try {
|
|
26345
|
-
const js = await demuxSegmentKeyframesJs(bytes);
|
|
26346
|
-
if (js && js.length > 0) return js;
|
|
26347
|
-
opts.onFfmpegFallback?.("mp4box parse found no keyframe");
|
|
26348
|
-
} catch (err) {
|
|
26349
|
-
opts.onFfmpegFallback?.(`mp4box parse threw: ${String(err)}`);
|
|
26350
|
-
}
|
|
26351
|
-
return demuxSegmentKeyframesToAnnexB(bytes, opts.ffmpegPath);
|
|
26352
|
-
}
|
|
26353
|
-
/**
|
|
26354
26757
|
* Pure-JS FULL-playback demux of one self-contained `.m4s` fragment: return
|
|
26355
26758
|
* EVERY access unit (sync + delta) as params-fused Annex-B with segment-relative
|
|
26356
26759
|
* `ptsMs`, matching the {@link AccessUnit} contract of {@link demuxSegmentToAnnexB}
|
|
@@ -26659,7 +27062,8 @@ var ADAPTIVE_CONFIG = {
|
|
|
26659
27062
|
minFailPercent: .05,
|
|
26660
27063
|
waitAfterResetMs: 8e3,
|
|
26661
27064
|
upgradeHealthyMs: 6e4,
|
|
26662
|
-
minTimeBetweenChangesMs: 6e4
|
|
27065
|
+
minTimeBetweenChangesMs: 6e4,
|
|
27066
|
+
maxUpgradeBackoffMs: 9e5
|
|
26663
27067
|
};
|
|
26664
27068
|
/**
|
|
26665
27069
|
* True when `broker` is an in-process StreamBroker exposing the Opus egress
|
|
@@ -26845,6 +27249,24 @@ var BrokerWebrtcServer = class {
|
|
|
26845
27249
|
/** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
|
|
26846
27250
|
brokers = /* @__PURE__ */ new Map();
|
|
26847
27251
|
/**
|
|
27252
|
+
* Post-session warmth: when the LAST WebRTC session on a broker closes, a
|
|
27253
|
+
* ghost subscription keeps the source dialed and its RTP ring warm for the
|
|
27254
|
+
* linger window, so the dominant remote pattern — reopen within minutes —
|
|
27255
|
+
* reconnects with an instant-start GOP replay instead of re-paying the
|
|
27256
|
+
* cold dial + next-IDR wait. `kind:'warmup'` reuses the existing consumer
|
|
27257
|
+
* attribution enum; the label answers "who keeps cam X warm?" in the
|
|
27258
|
+
* operator widget.
|
|
27259
|
+
*/
|
|
27260
|
+
brokerLinger = new BrokerLingerRegistry({ subscribe: (brokerId) => {
|
|
27261
|
+
const broker = this.brokers.get(brokerId);
|
|
27262
|
+
if (!broker) return null;
|
|
27263
|
+
return broker.onEncodedData(() => {}, {
|
|
27264
|
+
kind: "warmup",
|
|
27265
|
+
label: "webrtc-linger",
|
|
27266
|
+
media: "both"
|
|
27267
|
+
});
|
|
27268
|
+
} });
|
|
27269
|
+
/**
|
|
26848
27270
|
* Resolves a brokerId to its current profile tier (`CamProfile`)
|
|
26849
27271
|
* via the manager's `assignments` map, or `null` for brokers that
|
|
26850
27272
|
* are only kept alive by a manual activation. Injected by the
|
|
@@ -27007,16 +27429,20 @@ var BrokerWebrtcServer = class {
|
|
|
27007
27429
|
* RTC controller / Alexa handler closes the session the same way.
|
|
27008
27430
|
*/
|
|
27009
27431
|
async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
|
|
27432
|
+
const offerStart = Date.now();
|
|
27010
27433
|
const setup = await this.setupSessionForBroker(streamId, hints, {
|
|
27011
27434
|
...opts,
|
|
27012
27435
|
clientOfferSdp
|
|
27013
27436
|
});
|
|
27437
|
+
const setupMs = Date.now() - offerStart;
|
|
27014
27438
|
try {
|
|
27015
27439
|
if (opts.relayOnly === true) setup.session.setForceRelayOnly(true);
|
|
27440
|
+
const negotiateStart = Date.now();
|
|
27016
27441
|
const answer = await setup.session.handleOffer({
|
|
27017
27442
|
sdp: clientOfferSdp,
|
|
27018
27443
|
type: "offer"
|
|
27019
27444
|
}, { nonTrickle: opts.nonTrickle === true });
|
|
27445
|
+
const negotiateMs = Date.now() - negotiateStart;
|
|
27020
27446
|
setup.sessionLogger.info("WebRTC session created (client-offer)", { meta: {
|
|
27021
27447
|
sessionId: setup.sessionId,
|
|
27022
27448
|
deviceId: setup.deviceId,
|
|
@@ -27024,7 +27450,11 @@ var BrokerWebrtcServer = class {
|
|
|
27024
27450
|
iceServers: setup.iceServerCount,
|
|
27025
27451
|
codec: setup.sessionCodec,
|
|
27026
27452
|
useRtpRepacketizer: setup.useRtpRepacketizer,
|
|
27027
|
-
relayOnly: opts.relayOnly === true
|
|
27453
|
+
relayOnly: opts.relayOnly === true,
|
|
27454
|
+
setupMs,
|
|
27455
|
+
negotiateMs,
|
|
27456
|
+
totalMs: Date.now() - offerStart,
|
|
27457
|
+
...setup.setupTimings
|
|
27028
27458
|
} });
|
|
27029
27459
|
return {
|
|
27030
27460
|
sessionId: setup.sessionId,
|
|
@@ -27051,6 +27481,11 @@ var BrokerWebrtcServer = class {
|
|
|
27051
27481
|
*/
|
|
27052
27482
|
async setupSessionForBroker(streamId, hints, opts) {
|
|
27053
27483
|
if (this.stopped) throw new Error("Server stopped");
|
|
27484
|
+
const setupStart = Date.now();
|
|
27485
|
+
let ensureTierMs = 0;
|
|
27486
|
+
let warmUpMs = 0;
|
|
27487
|
+
let iceResolveMs = 0;
|
|
27488
|
+
let hwAccelMs = 0;
|
|
27054
27489
|
const isAdaptiveStream = streamId.endsWith("/adaptive");
|
|
27055
27490
|
const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
|
|
27056
27491
|
const resolveHints = intent !== void 0 ? {
|
|
@@ -27060,10 +27495,13 @@ var BrokerWebrtcServer = class {
|
|
|
27060
27495
|
if (isAdaptiveStream) {
|
|
27061
27496
|
const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
|
|
27062
27497
|
const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
|
|
27498
|
+
const ensureTierStart = Date.now();
|
|
27063
27499
|
try {
|
|
27064
27500
|
await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
|
|
27065
27501
|
} catch (err) {
|
|
27066
27502
|
throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${require_dist.errMsg(err)}`);
|
|
27503
|
+
} finally {
|
|
27504
|
+
ensureTierMs = Date.now() - ensureTierStart;
|
|
27067
27505
|
}
|
|
27068
27506
|
}
|
|
27069
27507
|
const brokerId = this.resolveBrokerId(streamId, resolveHints);
|
|
@@ -27085,6 +27523,7 @@ var BrokerWebrtcServer = class {
|
|
|
27085
27523
|
kind: "warmup",
|
|
27086
27524
|
media: "both"
|
|
27087
27525
|
});
|
|
27526
|
+
const warmUpStart = Date.now();
|
|
27088
27527
|
await new Promise((resolve) => {
|
|
27089
27528
|
const timer = setTimeout(() => {
|
|
27090
27529
|
sessionLogger.warn("broker SDP wait timed out — falling back to default codec", { meta: {
|
|
@@ -27100,9 +27539,11 @@ var BrokerWebrtcServer = class {
|
|
|
27100
27539
|
resolve();
|
|
27101
27540
|
});
|
|
27102
27541
|
});
|
|
27542
|
+
warmUpMs = Date.now() - warmUpStart;
|
|
27103
27543
|
sessionLogger.info("broker warm-up complete", { meta: {
|
|
27104
27544
|
brokerId,
|
|
27105
|
-
detectedCodec: broker.getStats().codec ?? null
|
|
27545
|
+
detectedCodec: broker.getStats().codec ?? null,
|
|
27546
|
+
waitedMs: warmUpMs
|
|
27106
27547
|
} });
|
|
27107
27548
|
}
|
|
27108
27549
|
const brokerCodec = broker.getStats().codec ?? "h264";
|
|
@@ -27286,6 +27727,7 @@ var BrokerWebrtcServer = class {
|
|
|
27286
27727
|
}
|
|
27287
27728
|
}, annexbAttribution);
|
|
27288
27729
|
warmUpUnsubscribe?.();
|
|
27730
|
+
this.brokerLinger.cancel(brokerId);
|
|
27289
27731
|
const preBuffer = broker.getPreBuffer();
|
|
27290
27732
|
const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
|
|
27291
27733
|
for (const pkt of preBuffer) if (pkt.type === "video") {
|
|
@@ -27312,7 +27754,9 @@ var BrokerWebrtcServer = class {
|
|
|
27312
27754
|
}
|
|
27313
27755
|
});
|
|
27314
27756
|
}
|
|
27757
|
+
const iceResolveStart = Date.now();
|
|
27315
27758
|
const allIceServers = await this.resolveIceServers();
|
|
27759
|
+
iceResolveMs = Date.now() - iceResolveStart;
|
|
27316
27760
|
const iceServers = opts.disableTurn === true ? allIceServers.map((entry) => {
|
|
27317
27761
|
const stunOnly = (Array.isArray(entry.urls) ? entry.urls : [entry.urls]).filter((u) => !u.startsWith("turn:") && !u.startsWith("turns:"));
|
|
27318
27762
|
if (stunOnly.length === 0) return null;
|
|
@@ -27337,13 +27781,16 @@ var BrokerWebrtcServer = class {
|
|
|
27337
27781
|
sourceProfileLevelId,
|
|
27338
27782
|
offeredProfileLevelIds
|
|
27339
27783
|
} });
|
|
27784
|
+
const hwAccelStart = Date.now();
|
|
27785
|
+
const transcodeDecodeHwAccel = await this.getTranscodeDecodeHwAccel();
|
|
27786
|
+
hwAccelMs = Date.now() - hwAccelStart;
|
|
27340
27787
|
session = new AdaptiveSession({
|
|
27341
27788
|
sessionId,
|
|
27342
27789
|
source,
|
|
27343
27790
|
sourceCodec: sessionCodec,
|
|
27344
27791
|
transcodeToBaseline,
|
|
27345
27792
|
transcodeProfile: effectiveTranscodeProfile,
|
|
27346
|
-
transcodeDecodeHwAccel
|
|
27793
|
+
transcodeDecodeHwAccel,
|
|
27347
27794
|
resolveTranscodeSourceUrl: () => this.sourceRestreamUrlResolver?.(brokerId) ?? null,
|
|
27348
27795
|
iceConfig: {
|
|
27349
27796
|
iceServers,
|
|
@@ -27455,7 +27902,6 @@ var BrokerWebrtcServer = class {
|
|
|
27455
27902
|
const locateRecorded = this.locateRecordedSegment;
|
|
27456
27903
|
const readRecorded = this.readRecordedSegmentBytes;
|
|
27457
27904
|
const resolveFfmpeg = this.resolveFfmpegPath;
|
|
27458
|
-
let scrubJsFallbackLogged = false;
|
|
27459
27905
|
let fullJsFallbackLogged = false;
|
|
27460
27906
|
let audioOpusFallbackLogged = false;
|
|
27461
27907
|
if (locateRecorded && readRecorded) {
|
|
@@ -27483,19 +27929,6 @@ var BrokerWebrtcServer = class {
|
|
|
27483
27929
|
} });
|
|
27484
27930
|
}
|
|
27485
27931
|
}),
|
|
27486
|
-
demuxScrub: (b) => demuxSegmentKeyframesPreferJs(b, {
|
|
27487
|
-
ffmpegPath: resolveFfmpeg?.(),
|
|
27488
|
-
onFfmpegFallback: (reason) => {
|
|
27489
|
-
if (scrubJsFallbackLogged) return;
|
|
27490
|
-
scrubJsFallbackLogged = true;
|
|
27491
|
-
sessionLogger.warn("recorded scrub: JS keyframe demux failed — ffmpeg fallback (once/session)", { meta: {
|
|
27492
|
-
sessionId,
|
|
27493
|
-
deviceId,
|
|
27494
|
-
profile,
|
|
27495
|
-
reason
|
|
27496
|
-
} });
|
|
27497
|
-
}
|
|
27498
|
-
}),
|
|
27499
27932
|
demuxAudio: opusSession ? async (b) => {
|
|
27500
27933
|
return (await demuxSegmentAudioOpusPreferJs(b, {
|
|
27501
27934
|
ffmpegPath: resolveFfmpeg?.(),
|
|
@@ -27531,10 +27964,17 @@ var BrokerWebrtcServer = class {
|
|
|
27531
27964
|
} });
|
|
27532
27965
|
timeline._emitState(s);
|
|
27533
27966
|
},
|
|
27534
|
-
onPosition: (epochMs) => timeline._emitPosition(epochMs)
|
|
27967
|
+
onPosition: (epochMs) => timeline._emitPosition(epochMs),
|
|
27968
|
+
onLog: (msg, data) => sessionLogger.info(msg, { meta: {
|
|
27969
|
+
sessionId,
|
|
27970
|
+
deviceId,
|
|
27971
|
+
profile,
|
|
27972
|
+
...data
|
|
27973
|
+
} })
|
|
27535
27974
|
});
|
|
27536
27975
|
}
|
|
27537
27976
|
});
|
|
27977
|
+
entry.timeline = timeline;
|
|
27538
27978
|
sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
|
|
27539
27979
|
sessionId,
|
|
27540
27980
|
deviceId
|
|
@@ -27584,7 +28024,7 @@ var BrokerWebrtcServer = class {
|
|
|
27584
28024
|
};
|
|
27585
28025
|
if (streamId.endsWith("/adaptive")) {
|
|
27586
28026
|
const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
|
|
27587
|
-
this.startAdaptive(entry, deviceId, initialTier, sessionId);
|
|
28027
|
+
this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures);
|
|
27588
28028
|
}
|
|
27589
28029
|
return {
|
|
27590
28030
|
sessionId,
|
|
@@ -27594,7 +28034,14 @@ var BrokerWebrtcServer = class {
|
|
|
27594
28034
|
brokerId,
|
|
27595
28035
|
sessionCodec,
|
|
27596
28036
|
useRtpRepacketizer,
|
|
27597
|
-
iceServerCount: iceServers.length
|
|
28037
|
+
iceServerCount: iceServers.length,
|
|
28038
|
+
setupTimings: {
|
|
28039
|
+
setupTotalMs: Date.now() - setupStart,
|
|
28040
|
+
ensureTierMs,
|
|
28041
|
+
warmUpMs,
|
|
28042
|
+
iceResolveMs,
|
|
28043
|
+
hwAccelMs
|
|
28044
|
+
}
|
|
27598
28045
|
};
|
|
27599
28046
|
}
|
|
27600
28047
|
async handleAnswer(sessionId, sdpAnswer) {
|
|
@@ -27644,6 +28091,7 @@ var BrokerWebrtcServer = class {
|
|
|
27644
28091
|
async stop() {
|
|
27645
28092
|
if (this.stopped) return;
|
|
27646
28093
|
this.stopped = true;
|
|
28094
|
+
this.brokerLinger.stop();
|
|
27647
28095
|
const closes = [];
|
|
27648
28096
|
for (const [sid, entry] of this.sessions) {
|
|
27649
28097
|
this.cleanupSession(sid);
|
|
@@ -27678,9 +28126,10 @@ var BrokerWebrtcServer = class {
|
|
|
27678
28126
|
* whichever produces the higher loss ratio across its window. The async
|
|
27679
28127
|
* poll is guarded against overlapping ticks via `tickInFlight`.
|
|
27680
28128
|
*/
|
|
27681
|
-
startAdaptive(entry, deviceId, initialTier, sessionId) {
|
|
28129
|
+
startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures) {
|
|
27682
28130
|
const controller = new AdaptiveController({
|
|
27683
28131
|
initialTier,
|
|
28132
|
+
priorFailures,
|
|
27684
28133
|
config: ADAPTIVE_CONFIG,
|
|
27685
28134
|
onSwitchTier: (tier) => {
|
|
27686
28135
|
this.switchSessionTier(entry, deviceId, tier, sessionId);
|
|
@@ -27700,6 +28149,10 @@ var BrokerWebrtcServer = class {
|
|
|
27700
28149
|
tickInFlight = true;
|
|
27701
28150
|
(async () => {
|
|
27702
28151
|
try {
|
|
28152
|
+
if (entry.timeline?.currentMode === "recorded") {
|
|
28153
|
+
controller.suspend(Date.now());
|
|
28154
|
+
return;
|
|
28155
|
+
}
|
|
27703
28156
|
const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
|
|
27704
28157
|
const now = Date.now();
|
|
27705
28158
|
if (pct !== null && pct !== void 0 && pct > 0) {
|
|
@@ -27731,9 +28184,11 @@ var BrokerWebrtcServer = class {
|
|
|
27731
28184
|
*/
|
|
27732
28185
|
switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
|
|
27733
28186
|
const prev = this.adaptiveIntent.get(sessionId);
|
|
28187
|
+
const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
|
|
27734
28188
|
this.adaptiveIntent.set(sessionId, {
|
|
27735
28189
|
prefersTier: tier,
|
|
27736
|
-
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false)
|
|
28190
|
+
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
|
|
28191
|
+
failures
|
|
27737
28192
|
});
|
|
27738
28193
|
const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
|
|
27739
28194
|
entry.session.pendingRenegotiation = {
|
|
@@ -27747,7 +28202,8 @@ var BrokerWebrtcServer = class {
|
|
|
27747
28202
|
deviceId,
|
|
27748
28203
|
tier,
|
|
27749
28204
|
forceTranscodeDown,
|
|
27750
|
-
epoch
|
|
28205
|
+
epoch,
|
|
28206
|
+
failures: Object.fromEntries(failures)
|
|
27751
28207
|
}
|
|
27752
28208
|
});
|
|
27753
28209
|
}
|
|
@@ -27755,6 +28211,12 @@ var BrokerWebrtcServer = class {
|
|
|
27755
28211
|
const entry = this.sessions.get(sessionId);
|
|
27756
28212
|
if (!entry) return;
|
|
27757
28213
|
this.sessions.delete(sessionId);
|
|
28214
|
+
let brokerStillServed = false;
|
|
28215
|
+
for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
|
|
28216
|
+
brokerStillServed = true;
|
|
28217
|
+
break;
|
|
28218
|
+
}
|
|
28219
|
+
if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
|
|
27758
28220
|
this.adaptiveIntent.delete(sessionId);
|
|
27759
28221
|
if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
|
|
27760
28222
|
if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
|
|
@@ -28338,6 +28800,42 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
|
|
|
28338
28800
|
}
|
|
28339
28801
|
};
|
|
28340
28802
|
//#endregion
|
|
28803
|
+
//#region src/stream-broker/event-loop-stall-monitor.ts
|
|
28804
|
+
var DEFAULT_INTERVAL_MS = 100;
|
|
28805
|
+
var DEFAULT_THRESHOLD_MS = 250;
|
|
28806
|
+
var EventLoopStallMonitor = class {
|
|
28807
|
+
intervalMs;
|
|
28808
|
+
thresholdMs;
|
|
28809
|
+
now;
|
|
28810
|
+
onStall;
|
|
28811
|
+
timer = null;
|
|
28812
|
+
lastTickAt = 0;
|
|
28813
|
+
constructor(options) {
|
|
28814
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
28815
|
+
this.thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS;
|
|
28816
|
+
this.now = options.now ?? (() => Date.now());
|
|
28817
|
+
this.onStall = options.onStall;
|
|
28818
|
+
}
|
|
28819
|
+
start() {
|
|
28820
|
+
if (this.timer) return;
|
|
28821
|
+
this.lastTickAt = this.now();
|
|
28822
|
+
this.timer = setInterval(() => {
|
|
28823
|
+
const at = this.now();
|
|
28824
|
+
const lateMs = at - this.lastTickAt - this.intervalMs;
|
|
28825
|
+
this.lastTickAt = at;
|
|
28826
|
+
if (lateMs >= this.thresholdMs) try {
|
|
28827
|
+
this.onStall(lateMs);
|
|
28828
|
+
} catch {}
|
|
28829
|
+
}, this.intervalMs);
|
|
28830
|
+
this.timer.unref?.();
|
|
28831
|
+
}
|
|
28832
|
+
stop() {
|
|
28833
|
+
if (!this.timer) return;
|
|
28834
|
+
clearInterval(this.timer);
|
|
28835
|
+
this.timer = null;
|
|
28836
|
+
}
|
|
28837
|
+
};
|
|
28838
|
+
//#endregion
|
|
28341
28839
|
//#region src/stream-broker/addon.ts
|
|
28342
28840
|
/**
|
|
28343
28841
|
* Hydrate the persisted RTSP restream state (`rtspTokens` / `rtspEnabled`)
|
|
@@ -28430,6 +28928,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
28430
28928
|
webrtcServer = null;
|
|
28431
28929
|
/** Handle for the embed SPA data-plane listener (disposed on shutdown). */
|
|
28432
28930
|
embedDataPlane = null;
|
|
28931
|
+
/** Names this process's event-loop stalls — see onInitialize. */
|
|
28932
|
+
loopStallMonitor = null;
|
|
28433
28933
|
/**
|
|
28434
28934
|
* Snapshot-equality cache for the broker-metrics emit. Each
|
|
28435
28935
|
* broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
|
|
@@ -28530,6 +29030,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
28530
29030
|
}
|
|
28531
29031
|
}
|
|
28532
29032
|
async onInitialize() {
|
|
29033
|
+
this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
|
|
29034
|
+
this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
|
|
29035
|
+
} });
|
|
29036
|
+
this.loopStallMonitor.start();
|
|
28533
29037
|
this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
|
|
28534
29038
|
this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
|
|
28535
29039
|
this.brokerManager.setEventBus(this.ctx.eventBus);
|
|
@@ -28872,6 +29376,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
28872
29376
|
];
|
|
28873
29377
|
}
|
|
28874
29378
|
async onShutdown() {
|
|
29379
|
+
this.loopStallMonitor?.stop();
|
|
29380
|
+
this.loopStallMonitor = null;
|
|
28875
29381
|
this.flushProbeSnapshots(Date.now(), true);
|
|
28876
29382
|
if (this.metricsSnapshotTimer) {
|
|
28877
29383
|
clearInterval(this.metricsSnapshotTimer);
|