@camstack/addon-pipeline 1.2.38 → 1.2.40
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-Du7_SNiR.mjs → dist-C6p8jcji.mjs} +495 -113
- package/dist/{dist-BvFLZyhW.js → dist-DcMBfoCg.js} +495 -113
- package/dist/{step-definitions-C8tk9fPJ.mjs → event-loop-stall-monitor-BHqCesa1.mjs} +139 -2
- package/dist/{step-definitions-i8O-hxyI.js → event-loop-stall-monitor-oL9xanvX.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-Bt1YkfUW.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DJnh97n9.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-BsNhZnfZ.mjs +26 -0
- package/dist/stream-broker/{hostInit-CfC64ye2.mjs → hostInit-Dyj8Vaql.mjs} +2 -2
- package/dist/stream-broker/index.js +723 -214
- package/dist/stream-broker/index.mjs +723 -214
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js → MaskShapeCanvas-DI4BY7W2-m1RaK_7F.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-9D-BWwQP.js → MotionZonesSettings-NcxxQN8r-C00UIgFo.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-HvAetfA4.js → PrivacyMaskSettings-APgPLF7p-DWYllToj.js} +1 -1
- package/embed-dist/assets/index-4fJzjj4H.css +2 -0
- package/embed-dist/assets/index-BaUgbxWl.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-DcMBfoCg.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: {
|
|
@@ -6994,14 +7058,16 @@ var RENDER_TIMEOUT_MS = 2e4;
|
|
|
6994
7058
|
function clipFfmpegArgs(input) {
|
|
6995
7059
|
const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
|
|
6996
7060
|
const demuxer = input.hevc === true ? "hevc" : "h264";
|
|
7061
|
+
const speed = input.speed ?? 2;
|
|
7062
|
+
const setpts = speed !== 1 ? `,setpts=PTS/${speed}` : "";
|
|
6997
7063
|
if (input.format === "gif") return [
|
|
6998
7064
|
"-y",
|
|
6999
7065
|
"-f",
|
|
7000
7066
|
demuxer,
|
|
7001
7067
|
"-i",
|
|
7002
7068
|
"pipe:0",
|
|
7003
|
-
"-
|
|
7004
|
-
`fps=${input.fps},${scale}`,
|
|
7069
|
+
"-filter_complex",
|
|
7070
|
+
`[0:v]fps=${input.fps}${setpts},${scale},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
|
|
7005
7071
|
"-loop",
|
|
7006
7072
|
"0",
|
|
7007
7073
|
"-f",
|
|
@@ -7015,7 +7081,7 @@ function clipFfmpegArgs(input) {
|
|
|
7015
7081
|
"-i",
|
|
7016
7082
|
"pipe:0",
|
|
7017
7083
|
"-vf",
|
|
7018
|
-
scale
|
|
7084
|
+
`${scale}${setpts}`,
|
|
7019
7085
|
"-c:v",
|
|
7020
7086
|
"libx264",
|
|
7021
7087
|
"-preset",
|
|
@@ -9735,6 +9801,21 @@ var DecoderSessionProxy = class {
|
|
|
9735
9801
|
this.api = api;
|
|
9736
9802
|
this.sessionId = sessionId;
|
|
9737
9803
|
}
|
|
9804
|
+
/**
|
|
9805
|
+
* Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
|
|
9806
|
+
* regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
|
|
9807
|
+
* `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
|
|
9808
|
+
* is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
|
|
9809
|
+
* peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
|
|
9810
|
+
* stub, an old decoder that predates `waitMs`, a test fake — would otherwise
|
|
9811
|
+
* turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
|
|
9812
|
+
* that pins the CPU, starves every timer in the process, and allocates until
|
|
9813
|
+
* the heap dies (observed: a 4 GB OOM in under 15 s).
|
|
9814
|
+
*/
|
|
9815
|
+
async paceEmptyDrain(drainStartedAt) {
|
|
9816
|
+
const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
|
|
9817
|
+
if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
|
|
9818
|
+
}
|
|
9738
9819
|
async pushPacket(packet) {
|
|
9739
9820
|
await this.api.pushPacket({
|
|
9740
9821
|
sessionId: this.sessionId,
|
|
@@ -9750,12 +9831,14 @@ var DecoderSessionProxy = class {
|
|
|
9750
9831
|
async startPolling(onFrame) {
|
|
9751
9832
|
this.polling = true;
|
|
9752
9833
|
while (this.polling) {
|
|
9834
|
+
const drainStartedAt = Date.now();
|
|
9753
9835
|
const frames = await this.api.pullFrames({
|
|
9754
9836
|
sessionId: this.sessionId,
|
|
9755
9837
|
maxCount: 4,
|
|
9756
9838
|
waitMs: POLL_WAIT_MS
|
|
9757
9839
|
});
|
|
9758
9840
|
for (const frame of frames) onFrame(frame);
|
|
9841
|
+
if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
|
|
9759
9842
|
}
|
|
9760
9843
|
}
|
|
9761
9844
|
/**
|
|
@@ -9773,12 +9856,14 @@ var DecoderSessionProxy = class {
|
|
|
9773
9856
|
async startHandlePolling(onHandle) {
|
|
9774
9857
|
this.polling = true;
|
|
9775
9858
|
while (this.polling) try {
|
|
9859
|
+
const drainStartedAt = Date.now();
|
|
9776
9860
|
const handles = await this.api.pullHandles({
|
|
9777
9861
|
sessionId: this.sessionId,
|
|
9778
9862
|
maxCount: 4,
|
|
9779
9863
|
waitMs: POLL_WAIT_MS
|
|
9780
9864
|
});
|
|
9781
9865
|
for (const handle of handles) onHandle(handle);
|
|
9866
|
+
if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
|
|
9782
9867
|
} catch (err) {
|
|
9783
9868
|
this.polling = false;
|
|
9784
9869
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -15726,6 +15811,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15726
15811
|
format: input.format,
|
|
15727
15812
|
maxWidth: input.maxWidth,
|
|
15728
15813
|
fps: input.fps,
|
|
15814
|
+
...input.speed !== void 0 ? { speed: input.speed } : {},
|
|
15729
15815
|
spawn: (cmd, args) => (0, node_child_process.spawn)(cmd, [...args], { stdio: [
|
|
15730
15816
|
"pipe",
|
|
15731
15817
|
"pipe",
|
|
@@ -17784,6 +17870,71 @@ function withSourceResolution(consumerDefault, probedSource) {
|
|
|
17784
17870
|
};
|
|
17785
17871
|
}
|
|
17786
17872
|
//#endregion
|
|
17873
|
+
//#region src/stream-broker/webrtc/broker-linger-registry.ts
|
|
17874
|
+
/** Matches the observed reopen gaps (1–2 min) with headroom; well under the
|
|
17875
|
+
* "operator walked away" scale where warmth would be waste. */
|
|
17876
|
+
var DEFAULT_LINGER_MS = 3 * 6e4;
|
|
17877
|
+
var BrokerLingerRegistry = class {
|
|
17878
|
+
lingerMs;
|
|
17879
|
+
subscribe;
|
|
17880
|
+
holds = /* @__PURE__ */ new Map();
|
|
17881
|
+
constructor(options) {
|
|
17882
|
+
this.lingerMs = options.lingerMs ?? DEFAULT_LINGER_MS;
|
|
17883
|
+
this.subscribe = options.subscribe;
|
|
17884
|
+
}
|
|
17885
|
+
/**
|
|
17886
|
+
* Start (or restart) the linger window for a broker that just lost its
|
|
17887
|
+
* last session. Re-holding an already-held broker re-arms the timer
|
|
17888
|
+
* without stacking a second ghost subscription.
|
|
17889
|
+
*/
|
|
17890
|
+
hold(brokerId) {
|
|
17891
|
+
const existing = this.holds.get(brokerId);
|
|
17892
|
+
if (existing) {
|
|
17893
|
+
clearTimeout(existing.timer);
|
|
17894
|
+
existing.timer = this.armTimer(brokerId);
|
|
17895
|
+
return;
|
|
17896
|
+
}
|
|
17897
|
+
const unsubscribe = this.subscribe(brokerId);
|
|
17898
|
+
if (unsubscribe === null) return;
|
|
17899
|
+
this.holds.set(brokerId, {
|
|
17900
|
+
unsubscribe,
|
|
17901
|
+
timer: this.armTimer(brokerId)
|
|
17902
|
+
});
|
|
17903
|
+
}
|
|
17904
|
+
/** Release the hold NOW — a new session attached, the real subscription
|
|
17905
|
+
* carries the demand from here. No-op when nothing is held. */
|
|
17906
|
+
cancel(brokerId) {
|
|
17907
|
+
const hold = this.holds.get(brokerId);
|
|
17908
|
+
if (!hold) return;
|
|
17909
|
+
clearTimeout(hold.timer);
|
|
17910
|
+
this.holds.delete(brokerId);
|
|
17911
|
+
this.release(hold);
|
|
17912
|
+
}
|
|
17913
|
+
/** Release every hold (server shutdown). */
|
|
17914
|
+
stop() {
|
|
17915
|
+
for (const [brokerId, hold] of this.holds) {
|
|
17916
|
+
clearTimeout(hold.timer);
|
|
17917
|
+
this.holds.delete(brokerId);
|
|
17918
|
+
this.release(hold);
|
|
17919
|
+
}
|
|
17920
|
+
}
|
|
17921
|
+
armTimer(brokerId) {
|
|
17922
|
+
const timer = setTimeout(() => {
|
|
17923
|
+
const hold = this.holds.get(brokerId);
|
|
17924
|
+
if (!hold) return;
|
|
17925
|
+
this.holds.delete(brokerId);
|
|
17926
|
+
this.release(hold);
|
|
17927
|
+
}, this.lingerMs);
|
|
17928
|
+
timer.unref?.();
|
|
17929
|
+
return timer;
|
|
17930
|
+
}
|
|
17931
|
+
release(hold) {
|
|
17932
|
+
try {
|
|
17933
|
+
hold.unsubscribe();
|
|
17934
|
+
} catch {}
|
|
17935
|
+
}
|
|
17936
|
+
};
|
|
17937
|
+
//#endregion
|
|
17787
17938
|
//#region src/stream-broker/webrtc/loss-tracker.ts
|
|
17788
17939
|
/** Delta between two RTCP extended (32-bit) highest-sequence values, with
|
|
17789
17940
|
* 32-bit wraparound handling. */
|
|
@@ -17832,6 +17983,24 @@ var LossTracker = class {
|
|
|
17832
17983
|
if (now < this.resetUntil) return false;
|
|
17833
17984
|
return this.lossRatio() > this.config.minFailPercent;
|
|
17834
17985
|
}
|
|
17986
|
+
/**
|
|
17987
|
+
* True when the window holds enough recent receiver feedback for a
|
|
17988
|
+
* "healthy" verdict to MEAN anything.
|
|
17989
|
+
*
|
|
17990
|
+
* `lossRatio()` returns 0 with fewer than two in-window samples, so
|
|
17991
|
+
* `isFailing` is false for a session that has produced NO feedback at all —
|
|
17992
|
+
* a link so bad that RTCP never arrives reads identically to a perfect one.
|
|
17993
|
+
* A downgrade may act on that (0 loss ⇒ don't downgrade, which is safe); an
|
|
17994
|
+
* UPGRADE must not, because it spends bandwidth the session has never been
|
|
17995
|
+
* shown to have. Silence is not health.
|
|
17996
|
+
*/
|
|
17997
|
+
hasEvidence(now) {
|
|
17998
|
+
if (now < this.resetUntil) return false;
|
|
17999
|
+
const cutoff = now - this.config.analyzeWindowMs;
|
|
18000
|
+
let inWindow = 0;
|
|
18001
|
+
for (const s of this.samples) if (s.time > cutoff) inWindow += 1;
|
|
18002
|
+
return inWindow >= 2;
|
|
18003
|
+
}
|
|
17835
18004
|
};
|
|
17836
18005
|
//#endregion
|
|
17837
18006
|
//#region src/stream-broker/webrtc/adaptive-controller.ts
|
|
@@ -17840,8 +18009,17 @@ var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
|
|
|
17840
18009
|
* Ladder state machine for an adaptive WebRTC session. Fed loss samples
|
|
17841
18010
|
* (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
|
|
17842
18011
|
* Downgrade order: tier high→mid→low, then MTU reduction at low, then
|
|
17843
|
-
* transcode-down.
|
|
17844
|
-
*
|
|
18012
|
+
* transcode-down. Side effects run through injected callbacks (testable
|
|
18013
|
+
* without werift).
|
|
18014
|
+
*
|
|
18015
|
+
* An UPGRADE requires all three of:
|
|
18016
|
+
* 1. the session clock — measured from the FIRST tick, not from the unix
|
|
18017
|
+
* epoch — showing `upgradeHealthyMs` without a failing verdict;
|
|
18018
|
+
* 2. actual receiver feedback in the window (`LossTracker.hasEvidence`) —
|
|
18019
|
+
* a session that has produced no RTCP at all is not "healthy", it is
|
|
18020
|
+
* silent, and climbing on silence spends bandwidth never shown to exist;
|
|
18021
|
+
* 3. the target tier's re-attempt backoff, which doubles per measured
|
|
18022
|
+
* failure of that tier in this session.
|
|
17845
18023
|
*/
|
|
17846
18024
|
var AdaptiveController = class {
|
|
17847
18025
|
opts;
|
|
@@ -17851,27 +18029,86 @@ var AdaptiveController = class {
|
|
|
17851
18029
|
transcodeEngaged = false;
|
|
17852
18030
|
lastChangeAt = 0;
|
|
17853
18031
|
healthySince = 0;
|
|
18032
|
+
/**
|
|
18033
|
+
* Whether the session clock has been armed by the first evaluation. The
|
|
18034
|
+
* clock is `Date.now()`, so leaving `healthySince` / `lastChangeAt` at 0
|
|
18035
|
+
* would read as "healthy since 1970" and hand out a free upgrade on the
|
|
18036
|
+
* very first tick, before a single loss sample had been seen. A boolean
|
|
18037
|
+
* (not a `startedAt === 0` sentinel) because 0 is a legitimate clock value.
|
|
18038
|
+
*/
|
|
18039
|
+
armed = false;
|
|
18040
|
+
failures;
|
|
17854
18041
|
constructor(opts) {
|
|
17855
18042
|
this.opts = opts;
|
|
17856
18043
|
this.tracker = new LossTracker(opts.config);
|
|
17857
18044
|
this.tier = opts.initialTier;
|
|
18045
|
+
this.failures = new Map(opts.priorFailures ?? []);
|
|
17858
18046
|
}
|
|
17859
18047
|
get currentTier() {
|
|
17860
18048
|
return this.tier;
|
|
17861
18049
|
}
|
|
18050
|
+
/**
|
|
18051
|
+
* Per-tier measured-failure counts, INCLUDING those inherited from earlier
|
|
18052
|
+
* rungs of the same viewer session. The server snapshots this into the
|
|
18053
|
+
* adaptive intent so the session's next controller inherits it.
|
|
18054
|
+
*/
|
|
18055
|
+
get failureCounts() {
|
|
18056
|
+
return this.failures;
|
|
18057
|
+
}
|
|
17862
18058
|
onLoss(sample, now) {
|
|
17863
18059
|
this.tracker.track(sample, now);
|
|
17864
18060
|
}
|
|
18061
|
+
/**
|
|
18062
|
+
* Freeze the ladder for this evaluation instead of ticking it.
|
|
18063
|
+
*
|
|
18064
|
+
* Called while the session's track is NOT carrying the live source — D42
|
|
18065
|
+
* put recorded playback and scrub on the same track with a moving source
|
|
18066
|
+
* pointer, and in those modes the live feed is gated, so receiver feedback
|
|
18067
|
+
* measures the recorded feeder, not the camera tier. Acting on it is
|
|
18068
|
+
* incoherent in both directions: a downgrade cannot shrink a file, an
|
|
18069
|
+
* upgrade cannot enlarge one, and EITHER destroys the feeder (a tier switch
|
|
18070
|
+
* is a close-and-recreate re-offer) — dropping the viewer back to live at
|
|
18071
|
+
* the wrong moment with nothing to restore the pointer.
|
|
18072
|
+
*
|
|
18073
|
+
* Holds the climb clock so time spent in recorded modes can never
|
|
18074
|
+
* accumulate into an upgrade on the return to live.
|
|
18075
|
+
*/
|
|
18076
|
+
suspend(now) {
|
|
18077
|
+
if (!this.armed) {
|
|
18078
|
+
this.armed = true;
|
|
18079
|
+
this.lastChangeAt = now;
|
|
18080
|
+
}
|
|
18081
|
+
this.healthySince = now;
|
|
18082
|
+
}
|
|
17865
18083
|
tick(now) {
|
|
18084
|
+
if (!this.armed) {
|
|
18085
|
+
this.armed = true;
|
|
18086
|
+
this.healthySince = now;
|
|
18087
|
+
this.lastChangeAt = now;
|
|
18088
|
+
}
|
|
17866
18089
|
if (this.tracker.isFailing(now)) {
|
|
17867
18090
|
this.healthySince = now;
|
|
17868
18091
|
this.handleFailing(now);
|
|
17869
18092
|
return;
|
|
17870
18093
|
}
|
|
18094
|
+
if (!this.tracker.hasEvidence(now)) {
|
|
18095
|
+
this.healthySince = now;
|
|
18096
|
+
return;
|
|
18097
|
+
}
|
|
17871
18098
|
this.handleHealthy(now);
|
|
17872
18099
|
}
|
|
17873
18100
|
canUpgrade(now) {
|
|
17874
|
-
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs
|
|
18101
|
+
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
|
|
18102
|
+
}
|
|
18103
|
+
/**
|
|
18104
|
+
* How long the link must read healthy before `tier` may be re-attempted.
|
|
18105
|
+
* Doubles per measured failure of that tier in this session, capped at
|
|
18106
|
+
* `maxUpgradeBackoffMs`.
|
|
18107
|
+
*/
|
|
18108
|
+
requiredHealthyMs(tier) {
|
|
18109
|
+
const failures = this.failures.get(tier) ?? 0;
|
|
18110
|
+
if (failures <= 0) return this.opts.config.upgradeHealthyMs;
|
|
18111
|
+
return Math.min(this.opts.config.upgradeHealthyMs * 2 ** failures, this.opts.config.maxUpgradeBackoffMs);
|
|
17875
18112
|
}
|
|
17876
18113
|
commitChange(now) {
|
|
17877
18114
|
this.lastChangeAt = now;
|
|
@@ -17880,6 +18117,8 @@ var AdaptiveController = class {
|
|
|
17880
18117
|
handleFailing(now) {
|
|
17881
18118
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
17882
18119
|
if (idx < TIER_ORDER.length - 1) {
|
|
18120
|
+
const failed = this.tier;
|
|
18121
|
+
this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
|
|
17883
18122
|
this.tier = TIER_ORDER[idx + 1];
|
|
17884
18123
|
this.opts.onSwitchTier(this.tier);
|
|
17885
18124
|
this.commitChange(now);
|
|
@@ -17898,17 +18137,17 @@ var AdaptiveController = class {
|
|
|
17898
18137
|
}
|
|
17899
18138
|
}
|
|
17900
18139
|
handleHealthy(now) {
|
|
17901
|
-
if (now - this.healthySince < this.opts.config.upgradeHealthyMs) return;
|
|
17902
|
-
if (!this.canUpgrade(now)) return;
|
|
17903
18140
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
17904
|
-
if (idx
|
|
17905
|
-
|
|
17906
|
-
|
|
17907
|
-
|
|
17908
|
-
|
|
17909
|
-
|
|
17910
|
-
|
|
17911
|
-
|
|
18141
|
+
if (idx <= 0) return;
|
|
18142
|
+
const next = TIER_ORDER[idx - 1];
|
|
18143
|
+
if (now - this.healthySince < this.requiredHealthyMs(next)) return;
|
|
18144
|
+
if (!this.canUpgrade(now)) return;
|
|
18145
|
+
this.tier = next;
|
|
18146
|
+
this.mtuReduced = false;
|
|
18147
|
+
this.transcodeEngaged = false;
|
|
18148
|
+
this.opts.onSwitchTier(next);
|
|
18149
|
+
this.commitChange(now);
|
|
18150
|
+
this.healthySince = now;
|
|
17912
18151
|
}
|
|
17913
18152
|
};
|
|
17914
18153
|
//#endregion
|
|
@@ -17933,6 +18172,7 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
|
|
|
17933
18172
|
t: require_dist.literal("scrubTo"),
|
|
17934
18173
|
epoch: require_dist.number()
|
|
17935
18174
|
}),
|
|
18175
|
+
require_dist.object({ t: require_dist.literal("scrubAck") }),
|
|
17936
18176
|
require_dist.object({
|
|
17937
18177
|
t: require_dist.literal("setRate"),
|
|
17938
18178
|
rate: require_dist.number().min(0).max(4)
|
|
@@ -18106,6 +18346,74 @@ var SCRUB_CACHE_MAX = 8;
|
|
|
18106
18346
|
* sustained direction, so a jittery/reversing drag never wastes the slot.
|
|
18107
18347
|
*/
|
|
18108
18348
|
var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
|
|
18349
|
+
/**
|
|
18350
|
+
* Max forward TARGET delta (ms of timeline, between consecutive drag targets)
|
|
18351
|
+
* that SWEEPS predicted frames instead of jumping. A steady forward drag is
|
|
18352
|
+
* playback with a moving target: once a keyframe decoded, the predicted frames
|
|
18353
|
+
* after it decode in sequence at a fraction of an IDR's bytes — that is the
|
|
18354
|
+
* continuous flow. Beyond this window (a fling), or backwards, a jump to an
|
|
18355
|
+
* independently-decodable keyframe is cheaper than the burst; and because a
|
|
18356
|
+
* thin pipe slows the ack-gated ticks down, per-tick deltas GROW under
|
|
18357
|
+
* congestion, so degradation to jump-only is emergent, and visible per push
|
|
18358
|
+
* (`mode` on every scrub-feeder:pushed line).
|
|
18359
|
+
*/
|
|
18360
|
+
var SCRUB_SWEEP_WINDOW_MS = 4e3;
|
|
18361
|
+
/**
|
|
18362
|
+
* Receiver-credit cap for scrub pushes: at most this many keyframes may be
|
|
18363
|
+
* OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
|
|
18364
|
+
* the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
|
|
18365
|
+
* a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
|
|
18366
|
+
* floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
|
|
18367
|
+
* Backpressure is the receiver's: a push spends a credit, a rendered frame
|
|
18368
|
+
* restores one, and with zero credit the newest target PARKS on
|
|
18369
|
+
* `scrubPendingEpochMs` until an ack arrives — a timer never releases work.
|
|
18370
|
+
*/
|
|
18371
|
+
var SCRUB_CREDIT_CAP = 2;
|
|
18372
|
+
/**
|
|
18373
|
+
* How far a `gap` locate may be SNAPPED onto real footage.
|
|
18374
|
+
*
|
|
18375
|
+
* A snap is a CORRECTION — the playhead landed a moment outside coverage and
|
|
18376
|
+
* the operator plainly means the footage beside it — not a SEARCH for wherever
|
|
18377
|
+
* footage happens to exist. The distinction is not cosmetic, because the index
|
|
18378
|
+
* that answers `locate` cannot tell "there is no footage here" apart from "I
|
|
18379
|
+
* have not walked this far back yet": it is rebuilt by walking the archive on
|
|
18380
|
+
* every recorder start, and that walk takes minutes.
|
|
18381
|
+
*
|
|
18382
|
+
* Production 2026-08-02: the recorder had restarted 9 minutes earlier, so
|
|
18383
|
+
* `locate` answered `gap` for the whole timeline with a `nearestEdgeMs` 6
|
|
18384
|
+
* minutes to 23 HOURS away. Unbounded snapping turned every seek and every drag
|
|
18385
|
+
* tick into a segment read against the addon that was busy re-walking, each one
|
|
18386
|
+
* blew the 8 s budget, and both surfaces died. Bounded, all of those answer
|
|
18387
|
+
* `gap` for free — exactly as they did before snapping existed.
|
|
18388
|
+
*
|
|
18389
|
+
* 120 s covers the real cases by a wide margin: an inter-segment crack is
|
|
18390
|
+
* 11–17 ms, and the dead band between two recording bands measured 48 s. Beyond
|
|
18391
|
+
* that the server is answering a question the viewer did not ask; the viewer
|
|
18392
|
+
* holds the coverage ranges and can offer "jump to nearest footage" as an
|
|
18393
|
+
* action instead.
|
|
18394
|
+
*/
|
|
18395
|
+
var SNAP_MAX_DISTANCE_MS = 12e4;
|
|
18396
|
+
/**
|
|
18397
|
+
* Decide where a `gap` locate should snap: forward to the next footage edge,
|
|
18398
|
+
* else backward onto the last ms of the footage behind (`prevEndMs − 1`, which
|
|
18399
|
+
* also hops the 11–17 ms inter-segment cracks). Rejects a candidate further than
|
|
18400
|
+
* {@link SNAP_MAX_DISTANCE_MS} so a partially-walked index cannot teleport the
|
|
18401
|
+
* cursor — and so rejecting costs NO segment I/O at all.
|
|
18402
|
+
*/
|
|
18403
|
+
function resolveSnapTarget(loc, requestedMs) {
|
|
18404
|
+
const snapMs = loc.nearestEdgeMs ?? (typeof loc.prevEndMs === "number" ? loc.prevEndMs - 1 : null);
|
|
18405
|
+
if (snapMs === null) return { kind: "none" };
|
|
18406
|
+
const distanceMs = Math.abs(snapMs - requestedMs);
|
|
18407
|
+
if (distanceMs > SNAP_MAX_DISTANCE_MS) return {
|
|
18408
|
+
kind: "too-far",
|
|
18409
|
+
snapMs,
|
|
18410
|
+
distanceMs
|
|
18411
|
+
};
|
|
18412
|
+
return {
|
|
18413
|
+
kind: "snap",
|
|
18414
|
+
snapMs
|
|
18415
|
+
};
|
|
18416
|
+
}
|
|
18109
18417
|
var RecordedFeeder = class {
|
|
18110
18418
|
deps;
|
|
18111
18419
|
clock = new MonotonicClock();
|
|
@@ -18148,6 +18456,16 @@ var RecordedFeeder = class {
|
|
|
18148
18456
|
*/
|
|
18149
18457
|
scrubbing = false;
|
|
18150
18458
|
scrubToken = 0;
|
|
18459
|
+
/** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
|
|
18460
|
+
scrubCredit = SCRUB_CREDIT_CAP;
|
|
18461
|
+
/**
|
|
18462
|
+
* Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
|
|
18463
|
+
* `1`; a sweep enqueues however many predicted frames it put on the wire. Each
|
|
18464
|
+
* `scrubAck` settles one frame off the head, and the credit that push spent
|
|
18465
|
+
* comes back only when the head reaches zero — so a sweep cannot be refunded
|
|
18466
|
+
* once per frame for the single credit it cost.
|
|
18467
|
+
*/
|
|
18468
|
+
scrubOutstanding = [];
|
|
18151
18469
|
/** The newest un-processed drag target (null when the tick cycle has drained). */
|
|
18152
18470
|
scrubPendingEpochMs = null;
|
|
18153
18471
|
/**
|
|
@@ -18178,6 +18496,15 @@ var RecordedFeeder = class {
|
|
|
18178
18496
|
* sign of `(newTarget − this)` is the drag direction that steers prefetch.
|
|
18179
18497
|
*/
|
|
18180
18498
|
scrubPrevTarget = null;
|
|
18499
|
+
/** The last AU actually pushed this drag — the sweep-continuity cursor
|
|
18500
|
+
* (which fragment, and how deep into its AU list). Null until a jump lands. */
|
|
18501
|
+
scrubCursor = null;
|
|
18502
|
+
/** Per-drag mode accounting for the `scrub-feeder:drag-end` summary. */
|
|
18503
|
+
scrubJumps = 0;
|
|
18504
|
+
scrubSweeps = 0;
|
|
18505
|
+
scrubAusPushed = 0;
|
|
18506
|
+
/** Once-per-drag guard: a dead-zone drag ticks fast; one line says it all. */
|
|
18507
|
+
scrubNoFootageLogged = false;
|
|
18181
18508
|
/**
|
|
18182
18509
|
* Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
|
|
18183
18510
|
* − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
|
|
@@ -18215,9 +18542,19 @@ var RecordedFeeder = class {
|
|
|
18215
18542
|
async seek(epochMs) {
|
|
18216
18543
|
const token = ++this.seekToken;
|
|
18217
18544
|
if (this.scrubbing) {
|
|
18545
|
+
this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
18546
|
+
jumps: this.scrubJumps,
|
|
18547
|
+
sweeps: this.scrubSweeps,
|
|
18548
|
+
aus: this.scrubAusPushed
|
|
18549
|
+
});
|
|
18218
18550
|
this.scrubbing = false;
|
|
18219
18551
|
this.scrubToken++;
|
|
18220
18552
|
this.scrubSegment = null;
|
|
18553
|
+
this.scrubCursor = null;
|
|
18554
|
+
this.scrubJumps = 0;
|
|
18555
|
+
this.scrubSweeps = 0;
|
|
18556
|
+
this.scrubAusPushed = 0;
|
|
18557
|
+
this.scrubOutstanding = [];
|
|
18221
18558
|
this.scrubCache.clear();
|
|
18222
18559
|
this.scrubPrevTarget = null;
|
|
18223
18560
|
this.scrubDirRun = 0;
|
|
@@ -18228,29 +18565,67 @@ var RecordedFeeder = class {
|
|
|
18228
18565
|
}
|
|
18229
18566
|
this.advanceToken++;
|
|
18230
18567
|
this.inFlightSeekEpochMs = epochMs;
|
|
18568
|
+
let snapInfo = null;
|
|
18231
18569
|
try {
|
|
18232
18570
|
this.deps.onState("loading");
|
|
18233
18571
|
if (this.clockHasAdvanced) {
|
|
18234
18572
|
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18235
18573
|
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18236
18574
|
}
|
|
18237
|
-
|
|
18575
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
18238
18576
|
deviceId: this.deps.deviceId,
|
|
18239
18577
|
profile: this.deps.profile,
|
|
18240
18578
|
epochMs
|
|
18241
18579
|
}));
|
|
18242
18580
|
if (this.disposed || token !== this.seekToken) return;
|
|
18581
|
+
let landMs = epochMs;
|
|
18243
18582
|
if (loc.kind === "gap") {
|
|
18244
|
-
|
|
18245
|
-
|
|
18246
|
-
|
|
18247
|
-
|
|
18248
|
-
|
|
18583
|
+
const decision = resolveSnapTarget(loc, epochMs);
|
|
18584
|
+
if (decision.kind !== "snap") {
|
|
18585
|
+
if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
|
|
18586
|
+
requestedMs: epochMs,
|
|
18587
|
+
nearestMs: decision.snapMs,
|
|
18588
|
+
distanceMs: decision.distanceMs
|
|
18589
|
+
});
|
|
18590
|
+
else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
|
|
18591
|
+
this.currentSegment = null;
|
|
18592
|
+
this.lastFedVideoIdx = -1;
|
|
18593
|
+
this.stopPositionReports();
|
|
18594
|
+
this.deps.onState("gap");
|
|
18595
|
+
return;
|
|
18596
|
+
}
|
|
18597
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
18598
|
+
deviceId: this.deps.deviceId,
|
|
18599
|
+
profile: this.deps.profile,
|
|
18600
|
+
epochMs: decision.snapMs
|
|
18601
|
+
}));
|
|
18602
|
+
if (this.disposed || token !== this.seekToken) return;
|
|
18603
|
+
if (snapped.kind !== "segment") {
|
|
18604
|
+
this.deps.onLog?.("recorded:seek-no-footage", {
|
|
18605
|
+
requestedMs: epochMs,
|
|
18606
|
+
snappedMs: decision.snapMs
|
|
18607
|
+
});
|
|
18608
|
+
this.currentSegment = null;
|
|
18609
|
+
this.lastFedVideoIdx = -1;
|
|
18610
|
+
this.stopPositionReports();
|
|
18611
|
+
this.deps.onState("gap");
|
|
18612
|
+
return;
|
|
18613
|
+
}
|
|
18614
|
+
this.deps.onLog?.("recorded:seek-snapped", {
|
|
18615
|
+
requestedMs: epochMs,
|
|
18616
|
+
snappedMs: decision.snapMs
|
|
18617
|
+
});
|
|
18618
|
+
loc = snapped;
|
|
18619
|
+
landMs = decision.snapMs;
|
|
18620
|
+
snapInfo = {
|
|
18621
|
+
requestedMs: epochMs,
|
|
18622
|
+
snappedMs: decision.snapMs
|
|
18623
|
+
};
|
|
18249
18624
|
}
|
|
18250
18625
|
this.cursorMs = loc.startMs;
|
|
18251
18626
|
const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
|
|
18252
18627
|
if (this.disposed || token !== this.seekToken || loaded === null) return;
|
|
18253
|
-
this.feedPrefix(loc, loaded.video,
|
|
18628
|
+
this.feedPrefix(loc, loaded.video, landMs);
|
|
18254
18629
|
if (this.rate === 0) {
|
|
18255
18630
|
this.deps.onState("paused");
|
|
18256
18631
|
this.deps.onPosition(this.cursorMs);
|
|
@@ -18270,9 +18645,20 @@ var RecordedFeeder = class {
|
|
|
18270
18645
|
},
|
|
18271
18646
|
resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
|
|
18272
18647
|
});
|
|
18273
|
-
} catch {
|
|
18648
|
+
} catch (err) {
|
|
18274
18649
|
if (!this.disposed && token === this.seekToken) {
|
|
18275
18650
|
this.stopPositionReports();
|
|
18651
|
+
if (snapInfo !== null) {
|
|
18652
|
+
this.deps.onLog?.("recorded:seek-snap-load-failed", {
|
|
18653
|
+
requestedMs: snapInfo.requestedMs,
|
|
18654
|
+
snappedMs: snapInfo.snappedMs,
|
|
18655
|
+
error: require_dist.errMsg(err)
|
|
18656
|
+
});
|
|
18657
|
+
this.currentSegment = null;
|
|
18658
|
+
this.lastFedVideoIdx = -1;
|
|
18659
|
+
this.deps.onState("gap");
|
|
18660
|
+
return;
|
|
18661
|
+
}
|
|
18276
18662
|
this.deps.onState("error");
|
|
18277
18663
|
}
|
|
18278
18664
|
} finally {
|
|
@@ -18327,8 +18713,15 @@ var RecordedFeeder = class {
|
|
|
18327
18713
|
this.scrubToken++;
|
|
18328
18714
|
this.scrubPendingEpochMs = null;
|
|
18329
18715
|
this.scrubSegment = null;
|
|
18716
|
+
this.scrubCredit = SCRUB_CREDIT_CAP;
|
|
18330
18717
|
this.scrubPrevTarget = null;
|
|
18331
18718
|
this.scrubDirRun = 0;
|
|
18719
|
+
this.scrubCursor = null;
|
|
18720
|
+
this.scrubJumps = 0;
|
|
18721
|
+
this.scrubSweeps = 0;
|
|
18722
|
+
this.scrubAusPushed = 0;
|
|
18723
|
+
this.scrubNoFootageLogged = false;
|
|
18724
|
+
this.scrubOutstanding = [];
|
|
18332
18725
|
if (this.scrubTimer) {
|
|
18333
18726
|
clearTimeout(this.scrubTimer);
|
|
18334
18727
|
this.scrubTimer = null;
|
|
@@ -18348,6 +18741,24 @@ var RecordedFeeder = class {
|
|
|
18348
18741
|
if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
18349
18742
|
}
|
|
18350
18743
|
/**
|
|
18744
|
+
* Receiver credit: the viewer RENDERED a scrub frame. Restores one push
|
|
18745
|
+
* credit (capped — over-acking never banks a burst) and, when a target sat
|
|
18746
|
+
* parked on an exhausted budget, resumes the tick. This — not any timer — is
|
|
18747
|
+
* what paces scrub pushes to what the link actually delivers.
|
|
18748
|
+
*/
|
|
18749
|
+
scrubAck() {
|
|
18750
|
+
if (this.disposed) return;
|
|
18751
|
+
const head = this.scrubOutstanding[0];
|
|
18752
|
+
if (head === void 0) return;
|
|
18753
|
+
if (head > 1) {
|
|
18754
|
+
this.scrubOutstanding[0] = head - 1;
|
|
18755
|
+
return;
|
|
18756
|
+
}
|
|
18757
|
+
this.scrubOutstanding.shift();
|
|
18758
|
+
this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
|
|
18759
|
+
if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
18760
|
+
}
|
|
18761
|
+
/**
|
|
18351
18762
|
* One coalesced feeder-scrub push. Captures the newest pending target + the
|
|
18352
18763
|
* current mode generation, resolves the covering fragment (reusing the memo'd
|
|
18353
18764
|
* one when the target stays inside it), picks the nearest keyframe ≤ the
|
|
@@ -18358,25 +18769,68 @@ var RecordedFeeder = class {
|
|
|
18358
18769
|
*/
|
|
18359
18770
|
async scrubTick() {
|
|
18360
18771
|
const gen = this.scrubToken;
|
|
18772
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) {
|
|
18773
|
+
this.scrubTimer = null;
|
|
18774
|
+
return;
|
|
18775
|
+
}
|
|
18776
|
+
if (this.scrubCredit <= 0) {
|
|
18777
|
+
this.scrubTimer = null;
|
|
18778
|
+
this.deps.onLog?.("scrub-feeder:parked", { pendingEpochMs: this.scrubPendingEpochMs });
|
|
18779
|
+
return;
|
|
18780
|
+
}
|
|
18361
18781
|
const target = this.scrubPendingEpochMs;
|
|
18362
18782
|
this.scrubPendingEpochMs = null;
|
|
18363
|
-
if (
|
|
18783
|
+
if (target === null) {
|
|
18364
18784
|
this.scrubTimer = null;
|
|
18365
18785
|
return;
|
|
18366
18786
|
}
|
|
18367
18787
|
try {
|
|
18368
|
-
|
|
18788
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
18369
18789
|
deviceId: this.deps.deviceId,
|
|
18370
18790
|
profile: this.deps.profile,
|
|
18371
18791
|
epochMs: target
|
|
18372
18792
|
}));
|
|
18373
18793
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18374
|
-
|
|
18794
|
+
let effTarget = target;
|
|
18795
|
+
if (loc.kind === "gap") {
|
|
18796
|
+
const decision = resolveSnapTarget(loc, target);
|
|
18797
|
+
if (decision.kind !== "snap") {
|
|
18798
|
+
if (!this.scrubNoFootageLogged) {
|
|
18799
|
+
this.scrubNoFootageLogged = true;
|
|
18800
|
+
if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
|
|
18801
|
+
epochMs: target,
|
|
18802
|
+
nearestMs: decision.snapMs,
|
|
18803
|
+
distanceMs: decision.distanceMs
|
|
18804
|
+
});
|
|
18805
|
+
else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
18806
|
+
}
|
|
18807
|
+
return;
|
|
18808
|
+
}
|
|
18809
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
18810
|
+
deviceId: this.deps.deviceId,
|
|
18811
|
+
profile: this.deps.profile,
|
|
18812
|
+
epochMs: decision.snapMs
|
|
18813
|
+
}));
|
|
18814
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18815
|
+
if (snapped.kind !== "segment") {
|
|
18816
|
+
if (!this.scrubNoFootageLogged) {
|
|
18817
|
+
this.scrubNoFootageLogged = true;
|
|
18818
|
+
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
18819
|
+
}
|
|
18820
|
+
return;
|
|
18821
|
+
}
|
|
18822
|
+
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
18823
|
+
requestedMs: target,
|
|
18824
|
+
snappedMs: decision.snapMs
|
|
18825
|
+
});
|
|
18826
|
+
loc = snapped;
|
|
18827
|
+
effTarget = decision.snapMs;
|
|
18828
|
+
}
|
|
18375
18829
|
let seg = this.scrubSegment;
|
|
18376
18830
|
if (!seg || seg.startMs !== loc.startMs) {
|
|
18377
18831
|
seg = this.scrubCacheGet(loc.startMs);
|
|
18378
18832
|
if (!seg) {
|
|
18379
|
-
const loaded = await this.loadAus(loc.startMs, false
|
|
18833
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
18380
18834
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
18381
18835
|
seg = {
|
|
18382
18836
|
startMs: loc.startMs,
|
|
@@ -18387,26 +18841,80 @@ var RecordedFeeder = class {
|
|
|
18387
18841
|
}
|
|
18388
18842
|
this.scrubSegment = seg;
|
|
18389
18843
|
}
|
|
18390
|
-
const
|
|
18391
|
-
|
|
18392
|
-
|
|
18393
|
-
|
|
18844
|
+
const prevTarget = this.scrubPrevTarget;
|
|
18845
|
+
const cursor = this.scrubCursor;
|
|
18846
|
+
const targetIdx = this.pickScrubIndex(seg, effTarget, false);
|
|
18847
|
+
const forwardDelta = prevTarget === null ? null : effTarget - prevTarget;
|
|
18848
|
+
const withinWindow = forwardDelta !== null && forwardDelta > 0 && forwardDelta <= SCRUB_SWEEP_WINDOW_MS;
|
|
18849
|
+
const sameFragment = cursor !== null && cursor.fragStartMs === seg.startMs;
|
|
18850
|
+
const adjacentForward = cursor !== null && seg.startMs === cursor.fragStartMs + cursor.fragDurMs;
|
|
18851
|
+
let aus;
|
|
18852
|
+
let firstIdx;
|
|
18853
|
+
let mode;
|
|
18854
|
+
let crossesFragment = false;
|
|
18855
|
+
if (withinWindow && sameFragment && cursor !== null && targetIdx > cursor.idx) {
|
|
18856
|
+
mode = "sweep";
|
|
18857
|
+
firstIdx = cursor.idx + 1;
|
|
18858
|
+
aus = seg.aus.slice(firstIdx, targetIdx + 1);
|
|
18859
|
+
} else if (withinWindow && adjacentForward) {
|
|
18860
|
+
mode = "sweep";
|
|
18861
|
+
firstIdx = 0;
|
|
18862
|
+
aus = seg.aus.slice(0, targetIdx + 1);
|
|
18863
|
+
crossesFragment = true;
|
|
18864
|
+
} else {
|
|
18865
|
+
mode = "jump";
|
|
18866
|
+
firstIdx = this.pickScrubIndex(seg, effTarget, true);
|
|
18867
|
+
const kf = seg.aus[firstIdx];
|
|
18868
|
+
aus = kf === void 0 ? [] : [kf];
|
|
18869
|
+
}
|
|
18870
|
+
const lastAu = aus[aus.length - 1];
|
|
18871
|
+
if (lastAu === void 0) {
|
|
18872
|
+
this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
|
|
18873
|
+
this.scrubPrevTarget = effTarget;
|
|
18874
|
+
return;
|
|
18875
|
+
}
|
|
18876
|
+
if (mode === "jump" && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18877
|
+
else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
|
|
18878
|
+
for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
18879
|
+
this.scrubOutstanding.push(aus.length);
|
|
18880
|
+
this.scrubCredit -= 1;
|
|
18394
18881
|
this.clockHasAdvanced = true;
|
|
18395
|
-
|
|
18396
|
-
this.
|
|
18397
|
-
|
|
18398
|
-
|
|
18882
|
+
if (mode === "jump") this.scrubJumps += 1;
|
|
18883
|
+
else this.scrubSweeps += 1;
|
|
18884
|
+
this.scrubAusPushed += aus.length;
|
|
18885
|
+
this.scrubCursor = {
|
|
18886
|
+
fragStartMs: seg.startMs,
|
|
18887
|
+
fragDurMs: seg.durMs,
|
|
18888
|
+
idx: firstIdx + aus.length - 1
|
|
18889
|
+
};
|
|
18890
|
+
this.deps.onLog?.("scrub-feeder:pushed", {
|
|
18891
|
+
epochMs: effTarget,
|
|
18892
|
+
mode,
|
|
18893
|
+
aus: aus.length,
|
|
18894
|
+
codec: lastAu.codec,
|
|
18895
|
+
bytes: lastAu.data.length,
|
|
18896
|
+
creditLeft: this.scrubCredit
|
|
18897
|
+
});
|
|
18898
|
+
this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
|
|
18899
|
+
this.maybeScrubPrefetch(seg, effTarget, gen);
|
|
18900
|
+
} catch (err) {
|
|
18901
|
+
this.deps.onLog?.("scrub-feeder:tick-failed", {
|
|
18902
|
+
epochMs: target,
|
|
18903
|
+
error: err instanceof Error ? err.message : String(err)
|
|
18904
|
+
});
|
|
18905
|
+
} finally {
|
|
18906
|
+
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
18907
|
else this.scrubTimer = null;
|
|
18400
18908
|
}
|
|
18401
18909
|
}
|
|
18402
18910
|
/**
|
|
18403
|
-
* Index of the nearest
|
|
18911
|
+
* Index of the nearest AU at-or-before the drag target within `seg`
|
|
18404
18912
|
* (default 0 — the fragment's params-fused IDR, which the demux contract
|
|
18405
|
-
* guarantees).
|
|
18406
|
-
*
|
|
18407
|
-
*
|
|
18913
|
+
* guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
|
|
18914
|
+
* mode, whose pushed frame must be standalone-decodable; sweep mode picks
|
|
18915
|
+
* ANY frame, which is what makes the flow frame-granular.
|
|
18408
18916
|
*/
|
|
18409
|
-
|
|
18917
|
+
pickScrubIndex(seg, target, keyframesOnly) {
|
|
18410
18918
|
const first = seg.aus[0];
|
|
18411
18919
|
if (!first) return 0;
|
|
18412
18920
|
const offsetMs = target - seg.startMs;
|
|
@@ -18415,7 +18923,7 @@ var RecordedFeeder = class {
|
|
|
18415
18923
|
const au = seg.aus[i];
|
|
18416
18924
|
if (!au) continue;
|
|
18417
18925
|
if (au.ptsMs - first.ptsMs > offsetMs) break;
|
|
18418
|
-
if (au.keyframe) idx = i;
|
|
18926
|
+
if (!keyframesOnly || au.keyframe) idx = i;
|
|
18419
18927
|
}
|
|
18420
18928
|
return idx;
|
|
18421
18929
|
}
|
|
@@ -18486,7 +18994,7 @@ var RecordedFeeder = class {
|
|
|
18486
18994
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18487
18995
|
if (loc.kind !== "segment") return;
|
|
18488
18996
|
if (this.scrubCache.has(loc.startMs)) return;
|
|
18489
|
-
const loaded = await this.loadAus(loc.startMs, false
|
|
18997
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
18490
18998
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
18491
18999
|
this.scrubCachePut({
|
|
18492
19000
|
startMs: loc.startMs,
|
|
@@ -18836,10 +19344,8 @@ var RecordedFeeder = class {
|
|
|
18836
19344
|
* access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
|
|
18837
19345
|
* (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
|
|
18838
19346
|
* 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) {
|
|
19347
|
+
* (paused scrubs would throw it away). */
|
|
19348
|
+
async loadAus(startMs, wantAudio = true) {
|
|
18843
19349
|
const token = this.seekToken;
|
|
18844
19350
|
const bytes = await this.withTimeout(this.deps.readBytes({
|
|
18845
19351
|
deviceId: this.deps.deviceId,
|
|
@@ -18847,8 +19353,7 @@ var RecordedFeeder = class {
|
|
|
18847
19353
|
startMs
|
|
18848
19354
|
}));
|
|
18849
19355
|
if (this.disposed || token !== this.seekToken) return null;
|
|
18850
|
-
const
|
|
18851
|
-
const video = await this.withTimeout(demux(bytes));
|
|
19356
|
+
const video = await this.withTimeout(this.deps.demux(bytes));
|
|
18852
19357
|
if (this.disposed || token !== this.seekToken) return null;
|
|
18853
19358
|
let audio = [];
|
|
18854
19359
|
if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
|
|
@@ -18894,6 +19399,11 @@ var RecordedFeeder = class {
|
|
|
18894
19399
|
}
|
|
18895
19400
|
/** Stop everything: invalidate in-flight work and tear down the position timer. */
|
|
18896
19401
|
dispose() {
|
|
19402
|
+
if (this.scrubbing) this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
19403
|
+
jumps: this.scrubJumps,
|
|
19404
|
+
sweeps: this.scrubSweeps,
|
|
19405
|
+
aus: this.scrubAusPushed
|
|
19406
|
+
});
|
|
18897
19407
|
this.disposed = true;
|
|
18898
19408
|
this.seekToken++;
|
|
18899
19409
|
this.advanceToken++;
|
|
@@ -18903,6 +19413,12 @@ var RecordedFeeder = class {
|
|
|
18903
19413
|
this.scrubCache.clear();
|
|
18904
19414
|
this.scrubPrevTarget = null;
|
|
18905
19415
|
this.scrubDirRun = 0;
|
|
19416
|
+
this.scrubCursor = null;
|
|
19417
|
+
this.scrubJumps = 0;
|
|
19418
|
+
this.scrubSweeps = 0;
|
|
19419
|
+
this.scrubAusPushed = 0;
|
|
19420
|
+
this.scrubNoFootageLogged = false;
|
|
19421
|
+
this.scrubOutstanding = [];
|
|
18906
19422
|
if (this.scrubTimer) {
|
|
18907
19423
|
clearTimeout(this.scrubTimer);
|
|
18908
19424
|
this.scrubTimer = null;
|
|
@@ -18930,6 +19446,16 @@ var TimelineSession = class {
|
|
|
18930
19446
|
this.deps = deps;
|
|
18931
19447
|
deps.session.setLiveFeedGate(false);
|
|
18932
19448
|
}
|
|
19449
|
+
/**
|
|
19450
|
+
* Which source the track is currently carrying. Read by the broker's
|
|
19451
|
+
* adaptive ladder, which must NOT act while the pointer is off live: a tier
|
|
19452
|
+
* switch is a close-and-recreate re-offer, and that disposes this
|
|
19453
|
+
* coordinator — the viewer silently lands back on live at the wrong moment
|
|
19454
|
+
* with no code path to restore the pointer.
|
|
19455
|
+
*/
|
|
19456
|
+
get currentMode() {
|
|
19457
|
+
return this.mode;
|
|
19458
|
+
}
|
|
18933
19459
|
async handleControl(msg) {
|
|
18934
19460
|
switch (msg.t) {
|
|
18935
19461
|
case "playRecorded":
|
|
@@ -18946,6 +19472,9 @@ var TimelineSession = class {
|
|
|
18946
19472
|
case "scrubTo":
|
|
18947
19473
|
if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
|
|
18948
19474
|
break;
|
|
19475
|
+
case "scrubAck":
|
|
19476
|
+
if (this.mode === "recorded") this.feeder?.scrubAck();
|
|
19477
|
+
break;
|
|
18949
19478
|
case "setRate":
|
|
18950
19479
|
this.feeder?.setRate(msg.rate);
|
|
18951
19480
|
break;
|
|
@@ -18953,6 +19482,7 @@ var TimelineSession = class {
|
|
|
18953
19482
|
if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
|
|
18954
19483
|
break;
|
|
18955
19484
|
case "setProfile":
|
|
19485
|
+
if (msg.profile === this.profile) break;
|
|
18956
19486
|
this.profile = msg.profile;
|
|
18957
19487
|
if (this.mode === "recorded") this.enterRecorded();
|
|
18958
19488
|
break;
|
|
@@ -25670,25 +26200,6 @@ async function extractSamples(segment, segmentStartMs) {
|
|
|
25670
26200
|
samples: collected
|
|
25671
26201
|
} : null;
|
|
25672
26202
|
}
|
|
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
26203
|
//#endregion
|
|
25693
26204
|
//#region src/stream-broker/recorded/avcc-annexb.ts
|
|
25694
26205
|
/**
|
|
@@ -26192,33 +26703,6 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
|
|
|
26192
26703
|
"pipe:1"
|
|
26193
26704
|
];
|
|
26194
26705
|
/**
|
|
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
26706
|
* Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
|
|
26223
26707
|
* access units.
|
|
26224
26708
|
*
|
|
@@ -26244,113 +26728,35 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
|
|
|
26244
26728
|
codec
|
|
26245
26729
|
}));
|
|
26246
26730
|
}
|
|
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
26731
|
//#endregion
|
|
26284
26732
|
//#region src/stream-broker/recorded/segment-demux-js.ts
|
|
26285
26733
|
/**
|
|
26286
26734
|
* segment-demux-js — PURE-JS in-process demux for recorded playback.
|
|
26287
26735
|
*
|
|
26288
|
-
*
|
|
26289
|
-
*
|
|
26290
|
-
*
|
|
26291
|
-
*
|
|
26292
|
-
* (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
|
|
26736
|
+
* {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} — FULL playback
|
|
26737
|
+
* (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
|
|
26738
|
+
* Both playback AND scrub demux through this since sweep mode (the scrub train
|
|
26739
|
+
* pushes predicted frames, so the whole GOP is needed either way).
|
|
26293
26740
|
*
|
|
26294
|
-
* Replaces the per-fragment ffmpeg spawn (
|
|
26295
|
-
*
|
|
26741
|
+
* Replaces the per-fragment ffmpeg spawn (a ~140 ms subprocess floor) with an
|
|
26742
|
+
* in-process mp4box parse. A recorded `.m4s`
|
|
26296
26743
|
* is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
|
|
26297
26744
|
* carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
|
|
26298
26745
|
* sample(s) are all in the bytes we already hold — no init segment to fetch.
|
|
26299
26746
|
*
|
|
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.
|
|
26747
|
+
* Flow: {@link extractSamples} (the proven mp4box demux the recorder scrub
|
|
26748
|
+
* module already ships — box parsing is NOT duplicated here) yields the codec
|
|
26749
|
+
* `description` + length-prefixed samples; sync samples are fused with their
|
|
26750
|
+
* parameter sets ({@link buildKeyframeAnnexB}), delta samples converted plain
|
|
26751
|
+
* ({@link avccSampleToAnnexB}). `ptsMs` is segment-relative (anchored to 0 at
|
|
26752
|
+
* the first sample), exactly like the ffmpeg demux's PES-derived deltas —
|
|
26753
|
+
* `clampIntoSegment`/position reporting are unaffected.
|
|
26313
26754
|
*/
|
|
26314
26755
|
/** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
|
|
26315
26756
|
function isHevcCodec(codec) {
|
|
26316
26757
|
return /^h[ev][vc]1\./i.test(codec);
|
|
26317
26758
|
}
|
|
26318
26759
|
/**
|
|
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
26760
|
* Pure-JS FULL-playback demux of one self-contained `.m4s` fragment: return
|
|
26355
26761
|
* EVERY access unit (sync + delta) as params-fused Annex-B with segment-relative
|
|
26356
26762
|
* `ptsMs`, matching the {@link AccessUnit} contract of {@link demuxSegmentToAnnexB}
|
|
@@ -26659,7 +27065,8 @@ var ADAPTIVE_CONFIG = {
|
|
|
26659
27065
|
minFailPercent: .05,
|
|
26660
27066
|
waitAfterResetMs: 8e3,
|
|
26661
27067
|
upgradeHealthyMs: 6e4,
|
|
26662
|
-
minTimeBetweenChangesMs: 6e4
|
|
27068
|
+
minTimeBetweenChangesMs: 6e4,
|
|
27069
|
+
maxUpgradeBackoffMs: 9e5
|
|
26663
27070
|
};
|
|
26664
27071
|
/**
|
|
26665
27072
|
* True when `broker` is an in-process StreamBroker exposing the Opus egress
|
|
@@ -26845,6 +27252,24 @@ var BrokerWebrtcServer = class {
|
|
|
26845
27252
|
/** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
|
|
26846
27253
|
brokers = /* @__PURE__ */ new Map();
|
|
26847
27254
|
/**
|
|
27255
|
+
* Post-session warmth: when the LAST WebRTC session on a broker closes, a
|
|
27256
|
+
* ghost subscription keeps the source dialed and its RTP ring warm for the
|
|
27257
|
+
* linger window, so the dominant remote pattern — reopen within minutes —
|
|
27258
|
+
* reconnects with an instant-start GOP replay instead of re-paying the
|
|
27259
|
+
* cold dial + next-IDR wait. `kind:'warmup'` reuses the existing consumer
|
|
27260
|
+
* attribution enum; the label answers "who keeps cam X warm?" in the
|
|
27261
|
+
* operator widget.
|
|
27262
|
+
*/
|
|
27263
|
+
brokerLinger = new BrokerLingerRegistry({ subscribe: (brokerId) => {
|
|
27264
|
+
const broker = this.brokers.get(brokerId);
|
|
27265
|
+
if (!broker) return null;
|
|
27266
|
+
return broker.onEncodedData(() => {}, {
|
|
27267
|
+
kind: "warmup",
|
|
27268
|
+
label: "webrtc-linger",
|
|
27269
|
+
media: "both"
|
|
27270
|
+
});
|
|
27271
|
+
} });
|
|
27272
|
+
/**
|
|
26848
27273
|
* Resolves a brokerId to its current profile tier (`CamProfile`)
|
|
26849
27274
|
* via the manager's `assignments` map, or `null` for brokers that
|
|
26850
27275
|
* are only kept alive by a manual activation. Injected by the
|
|
@@ -27007,16 +27432,20 @@ var BrokerWebrtcServer = class {
|
|
|
27007
27432
|
* RTC controller / Alexa handler closes the session the same way.
|
|
27008
27433
|
*/
|
|
27009
27434
|
async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
|
|
27435
|
+
const offerStart = Date.now();
|
|
27010
27436
|
const setup = await this.setupSessionForBroker(streamId, hints, {
|
|
27011
27437
|
...opts,
|
|
27012
27438
|
clientOfferSdp
|
|
27013
27439
|
});
|
|
27440
|
+
const setupMs = Date.now() - offerStart;
|
|
27014
27441
|
try {
|
|
27015
27442
|
if (opts.relayOnly === true) setup.session.setForceRelayOnly(true);
|
|
27443
|
+
const negotiateStart = Date.now();
|
|
27016
27444
|
const answer = await setup.session.handleOffer({
|
|
27017
27445
|
sdp: clientOfferSdp,
|
|
27018
27446
|
type: "offer"
|
|
27019
27447
|
}, { nonTrickle: opts.nonTrickle === true });
|
|
27448
|
+
const negotiateMs = Date.now() - negotiateStart;
|
|
27020
27449
|
setup.sessionLogger.info("WebRTC session created (client-offer)", { meta: {
|
|
27021
27450
|
sessionId: setup.sessionId,
|
|
27022
27451
|
deviceId: setup.deviceId,
|
|
@@ -27024,7 +27453,11 @@ var BrokerWebrtcServer = class {
|
|
|
27024
27453
|
iceServers: setup.iceServerCount,
|
|
27025
27454
|
codec: setup.sessionCodec,
|
|
27026
27455
|
useRtpRepacketizer: setup.useRtpRepacketizer,
|
|
27027
|
-
relayOnly: opts.relayOnly === true
|
|
27456
|
+
relayOnly: opts.relayOnly === true,
|
|
27457
|
+
setupMs,
|
|
27458
|
+
negotiateMs,
|
|
27459
|
+
totalMs: Date.now() - offerStart,
|
|
27460
|
+
...setup.setupTimings
|
|
27028
27461
|
} });
|
|
27029
27462
|
return {
|
|
27030
27463
|
sessionId: setup.sessionId,
|
|
@@ -27051,6 +27484,11 @@ var BrokerWebrtcServer = class {
|
|
|
27051
27484
|
*/
|
|
27052
27485
|
async setupSessionForBroker(streamId, hints, opts) {
|
|
27053
27486
|
if (this.stopped) throw new Error("Server stopped");
|
|
27487
|
+
const setupStart = Date.now();
|
|
27488
|
+
let ensureTierMs = 0;
|
|
27489
|
+
let warmUpMs = 0;
|
|
27490
|
+
let iceResolveMs = 0;
|
|
27491
|
+
let hwAccelMs = 0;
|
|
27054
27492
|
const isAdaptiveStream = streamId.endsWith("/adaptive");
|
|
27055
27493
|
const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
|
|
27056
27494
|
const resolveHints = intent !== void 0 ? {
|
|
@@ -27060,10 +27498,13 @@ var BrokerWebrtcServer = class {
|
|
|
27060
27498
|
if (isAdaptiveStream) {
|
|
27061
27499
|
const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
|
|
27062
27500
|
const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
|
|
27501
|
+
const ensureTierStart = Date.now();
|
|
27063
27502
|
try {
|
|
27064
27503
|
await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
|
|
27065
27504
|
} catch (err) {
|
|
27066
27505
|
throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${require_dist.errMsg(err)}`);
|
|
27506
|
+
} finally {
|
|
27507
|
+
ensureTierMs = Date.now() - ensureTierStart;
|
|
27067
27508
|
}
|
|
27068
27509
|
}
|
|
27069
27510
|
const brokerId = this.resolveBrokerId(streamId, resolveHints);
|
|
@@ -27085,6 +27526,7 @@ var BrokerWebrtcServer = class {
|
|
|
27085
27526
|
kind: "warmup",
|
|
27086
27527
|
media: "both"
|
|
27087
27528
|
});
|
|
27529
|
+
const warmUpStart = Date.now();
|
|
27088
27530
|
await new Promise((resolve) => {
|
|
27089
27531
|
const timer = setTimeout(() => {
|
|
27090
27532
|
sessionLogger.warn("broker SDP wait timed out — falling back to default codec", { meta: {
|
|
@@ -27100,9 +27542,11 @@ var BrokerWebrtcServer = class {
|
|
|
27100
27542
|
resolve();
|
|
27101
27543
|
});
|
|
27102
27544
|
});
|
|
27545
|
+
warmUpMs = Date.now() - warmUpStart;
|
|
27103
27546
|
sessionLogger.info("broker warm-up complete", { meta: {
|
|
27104
27547
|
brokerId,
|
|
27105
|
-
detectedCodec: broker.getStats().codec ?? null
|
|
27548
|
+
detectedCodec: broker.getStats().codec ?? null,
|
|
27549
|
+
waitedMs: warmUpMs
|
|
27106
27550
|
} });
|
|
27107
27551
|
}
|
|
27108
27552
|
const brokerCodec = broker.getStats().codec ?? "h264";
|
|
@@ -27286,6 +27730,7 @@ var BrokerWebrtcServer = class {
|
|
|
27286
27730
|
}
|
|
27287
27731
|
}, annexbAttribution);
|
|
27288
27732
|
warmUpUnsubscribe?.();
|
|
27733
|
+
this.brokerLinger.cancel(brokerId);
|
|
27289
27734
|
const preBuffer = broker.getPreBuffer();
|
|
27290
27735
|
const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
|
|
27291
27736
|
for (const pkt of preBuffer) if (pkt.type === "video") {
|
|
@@ -27312,7 +27757,9 @@ var BrokerWebrtcServer = class {
|
|
|
27312
27757
|
}
|
|
27313
27758
|
});
|
|
27314
27759
|
}
|
|
27760
|
+
const iceResolveStart = Date.now();
|
|
27315
27761
|
const allIceServers = await this.resolveIceServers();
|
|
27762
|
+
iceResolveMs = Date.now() - iceResolveStart;
|
|
27316
27763
|
const iceServers = opts.disableTurn === true ? allIceServers.map((entry) => {
|
|
27317
27764
|
const stunOnly = (Array.isArray(entry.urls) ? entry.urls : [entry.urls]).filter((u) => !u.startsWith("turn:") && !u.startsWith("turns:"));
|
|
27318
27765
|
if (stunOnly.length === 0) return null;
|
|
@@ -27337,13 +27784,16 @@ var BrokerWebrtcServer = class {
|
|
|
27337
27784
|
sourceProfileLevelId,
|
|
27338
27785
|
offeredProfileLevelIds
|
|
27339
27786
|
} });
|
|
27787
|
+
const hwAccelStart = Date.now();
|
|
27788
|
+
const transcodeDecodeHwAccel = await this.getTranscodeDecodeHwAccel();
|
|
27789
|
+
hwAccelMs = Date.now() - hwAccelStart;
|
|
27340
27790
|
session = new AdaptiveSession({
|
|
27341
27791
|
sessionId,
|
|
27342
27792
|
source,
|
|
27343
27793
|
sourceCodec: sessionCodec,
|
|
27344
27794
|
transcodeToBaseline,
|
|
27345
27795
|
transcodeProfile: effectiveTranscodeProfile,
|
|
27346
|
-
transcodeDecodeHwAccel
|
|
27796
|
+
transcodeDecodeHwAccel,
|
|
27347
27797
|
resolveTranscodeSourceUrl: () => this.sourceRestreamUrlResolver?.(brokerId) ?? null,
|
|
27348
27798
|
iceConfig: {
|
|
27349
27799
|
iceServers,
|
|
@@ -27455,7 +27905,6 @@ var BrokerWebrtcServer = class {
|
|
|
27455
27905
|
const locateRecorded = this.locateRecordedSegment;
|
|
27456
27906
|
const readRecorded = this.readRecordedSegmentBytes;
|
|
27457
27907
|
const resolveFfmpeg = this.resolveFfmpegPath;
|
|
27458
|
-
let scrubJsFallbackLogged = false;
|
|
27459
27908
|
let fullJsFallbackLogged = false;
|
|
27460
27909
|
let audioOpusFallbackLogged = false;
|
|
27461
27910
|
if (locateRecorded && readRecorded) {
|
|
@@ -27483,19 +27932,6 @@ var BrokerWebrtcServer = class {
|
|
|
27483
27932
|
} });
|
|
27484
27933
|
}
|
|
27485
27934
|
}),
|
|
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
27935
|
demuxAudio: opusSession ? async (b) => {
|
|
27500
27936
|
return (await demuxSegmentAudioOpusPreferJs(b, {
|
|
27501
27937
|
ffmpegPath: resolveFfmpeg?.(),
|
|
@@ -27531,10 +27967,17 @@ var BrokerWebrtcServer = class {
|
|
|
27531
27967
|
} });
|
|
27532
27968
|
timeline._emitState(s);
|
|
27533
27969
|
},
|
|
27534
|
-
onPosition: (epochMs) => timeline._emitPosition(epochMs)
|
|
27970
|
+
onPosition: (epochMs) => timeline._emitPosition(epochMs),
|
|
27971
|
+
onLog: (msg, data) => sessionLogger.info(msg, { meta: {
|
|
27972
|
+
sessionId,
|
|
27973
|
+
deviceId,
|
|
27974
|
+
profile,
|
|
27975
|
+
...data
|
|
27976
|
+
} })
|
|
27535
27977
|
});
|
|
27536
27978
|
}
|
|
27537
27979
|
});
|
|
27980
|
+
entry.timeline = timeline;
|
|
27538
27981
|
sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
|
|
27539
27982
|
sessionId,
|
|
27540
27983
|
deviceId
|
|
@@ -27584,7 +28027,7 @@ var BrokerWebrtcServer = class {
|
|
|
27584
28027
|
};
|
|
27585
28028
|
if (streamId.endsWith("/adaptive")) {
|
|
27586
28029
|
const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
|
|
27587
|
-
this.startAdaptive(entry, deviceId, initialTier, sessionId);
|
|
28030
|
+
this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures);
|
|
27588
28031
|
}
|
|
27589
28032
|
return {
|
|
27590
28033
|
sessionId,
|
|
@@ -27594,7 +28037,14 @@ var BrokerWebrtcServer = class {
|
|
|
27594
28037
|
brokerId,
|
|
27595
28038
|
sessionCodec,
|
|
27596
28039
|
useRtpRepacketizer,
|
|
27597
|
-
iceServerCount: iceServers.length
|
|
28040
|
+
iceServerCount: iceServers.length,
|
|
28041
|
+
setupTimings: {
|
|
28042
|
+
setupTotalMs: Date.now() - setupStart,
|
|
28043
|
+
ensureTierMs,
|
|
28044
|
+
warmUpMs,
|
|
28045
|
+
iceResolveMs,
|
|
28046
|
+
hwAccelMs
|
|
28047
|
+
}
|
|
27598
28048
|
};
|
|
27599
28049
|
}
|
|
27600
28050
|
async handleAnswer(sessionId, sdpAnswer) {
|
|
@@ -27644,6 +28094,7 @@ var BrokerWebrtcServer = class {
|
|
|
27644
28094
|
async stop() {
|
|
27645
28095
|
if (this.stopped) return;
|
|
27646
28096
|
this.stopped = true;
|
|
28097
|
+
this.brokerLinger.stop();
|
|
27647
28098
|
const closes = [];
|
|
27648
28099
|
for (const [sid, entry] of this.sessions) {
|
|
27649
28100
|
this.cleanupSession(sid);
|
|
@@ -27678,9 +28129,10 @@ var BrokerWebrtcServer = class {
|
|
|
27678
28129
|
* whichever produces the higher loss ratio across its window. The async
|
|
27679
28130
|
* poll is guarded against overlapping ticks via `tickInFlight`.
|
|
27680
28131
|
*/
|
|
27681
|
-
startAdaptive(entry, deviceId, initialTier, sessionId) {
|
|
28132
|
+
startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures) {
|
|
27682
28133
|
const controller = new AdaptiveController({
|
|
27683
28134
|
initialTier,
|
|
28135
|
+
priorFailures,
|
|
27684
28136
|
config: ADAPTIVE_CONFIG,
|
|
27685
28137
|
onSwitchTier: (tier) => {
|
|
27686
28138
|
this.switchSessionTier(entry, deviceId, tier, sessionId);
|
|
@@ -27700,6 +28152,10 @@ var BrokerWebrtcServer = class {
|
|
|
27700
28152
|
tickInFlight = true;
|
|
27701
28153
|
(async () => {
|
|
27702
28154
|
try {
|
|
28155
|
+
if (entry.timeline?.currentMode === "recorded") {
|
|
28156
|
+
controller.suspend(Date.now());
|
|
28157
|
+
return;
|
|
28158
|
+
}
|
|
27703
28159
|
const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
|
|
27704
28160
|
const now = Date.now();
|
|
27705
28161
|
if (pct !== null && pct !== void 0 && pct > 0) {
|
|
@@ -27731,9 +28187,11 @@ var BrokerWebrtcServer = class {
|
|
|
27731
28187
|
*/
|
|
27732
28188
|
switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
|
|
27733
28189
|
const prev = this.adaptiveIntent.get(sessionId);
|
|
28190
|
+
const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
|
|
27734
28191
|
this.adaptiveIntent.set(sessionId, {
|
|
27735
28192
|
prefersTier: tier,
|
|
27736
|
-
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false)
|
|
28193
|
+
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
|
|
28194
|
+
failures
|
|
27737
28195
|
});
|
|
27738
28196
|
const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
|
|
27739
28197
|
entry.session.pendingRenegotiation = {
|
|
@@ -27747,7 +28205,8 @@ var BrokerWebrtcServer = class {
|
|
|
27747
28205
|
deviceId,
|
|
27748
28206
|
tier,
|
|
27749
28207
|
forceTranscodeDown,
|
|
27750
|
-
epoch
|
|
28208
|
+
epoch,
|
|
28209
|
+
failures: Object.fromEntries(failures)
|
|
27751
28210
|
}
|
|
27752
28211
|
});
|
|
27753
28212
|
}
|
|
@@ -27755,6 +28214,12 @@ var BrokerWebrtcServer = class {
|
|
|
27755
28214
|
const entry = this.sessions.get(sessionId);
|
|
27756
28215
|
if (!entry) return;
|
|
27757
28216
|
this.sessions.delete(sessionId);
|
|
28217
|
+
let brokerStillServed = false;
|
|
28218
|
+
for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
|
|
28219
|
+
brokerStillServed = true;
|
|
28220
|
+
break;
|
|
28221
|
+
}
|
|
28222
|
+
if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
|
|
27758
28223
|
this.adaptiveIntent.delete(sessionId);
|
|
27759
28224
|
if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
|
|
27760
28225
|
if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
|
|
@@ -28338,6 +28803,42 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
|
|
|
28338
28803
|
}
|
|
28339
28804
|
};
|
|
28340
28805
|
//#endregion
|
|
28806
|
+
//#region src/stream-broker/event-loop-stall-monitor.ts
|
|
28807
|
+
var DEFAULT_INTERVAL_MS = 100;
|
|
28808
|
+
var DEFAULT_THRESHOLD_MS = 250;
|
|
28809
|
+
var EventLoopStallMonitor = class {
|
|
28810
|
+
intervalMs;
|
|
28811
|
+
thresholdMs;
|
|
28812
|
+
now;
|
|
28813
|
+
onStall;
|
|
28814
|
+
timer = null;
|
|
28815
|
+
lastTickAt = 0;
|
|
28816
|
+
constructor(options) {
|
|
28817
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
28818
|
+
this.thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS;
|
|
28819
|
+
this.now = options.now ?? (() => Date.now());
|
|
28820
|
+
this.onStall = options.onStall;
|
|
28821
|
+
}
|
|
28822
|
+
start() {
|
|
28823
|
+
if (this.timer) return;
|
|
28824
|
+
this.lastTickAt = this.now();
|
|
28825
|
+
this.timer = setInterval(() => {
|
|
28826
|
+
const at = this.now();
|
|
28827
|
+
const lateMs = at - this.lastTickAt - this.intervalMs;
|
|
28828
|
+
this.lastTickAt = at;
|
|
28829
|
+
if (lateMs >= this.thresholdMs) try {
|
|
28830
|
+
this.onStall(lateMs);
|
|
28831
|
+
} catch {}
|
|
28832
|
+
}, this.intervalMs);
|
|
28833
|
+
this.timer.unref?.();
|
|
28834
|
+
}
|
|
28835
|
+
stop() {
|
|
28836
|
+
if (!this.timer) return;
|
|
28837
|
+
clearInterval(this.timer);
|
|
28838
|
+
this.timer = null;
|
|
28839
|
+
}
|
|
28840
|
+
};
|
|
28841
|
+
//#endregion
|
|
28341
28842
|
//#region src/stream-broker/addon.ts
|
|
28342
28843
|
/**
|
|
28343
28844
|
* Hydrate the persisted RTSP restream state (`rtspTokens` / `rtspEnabled`)
|
|
@@ -28430,6 +28931,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
28430
28931
|
webrtcServer = null;
|
|
28431
28932
|
/** Handle for the embed SPA data-plane listener (disposed on shutdown). */
|
|
28432
28933
|
embedDataPlane = null;
|
|
28934
|
+
/** Names this process's event-loop stalls — see onInitialize. */
|
|
28935
|
+
loopStallMonitor = null;
|
|
28433
28936
|
/**
|
|
28434
28937
|
* Snapshot-equality cache for the broker-metrics emit. Each
|
|
28435
28938
|
* broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
|
|
@@ -28530,6 +29033,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
28530
29033
|
}
|
|
28531
29034
|
}
|
|
28532
29035
|
async onInitialize() {
|
|
29036
|
+
this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
|
|
29037
|
+
this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
|
|
29038
|
+
} });
|
|
29039
|
+
this.loopStallMonitor.start();
|
|
28533
29040
|
this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
|
|
28534
29041
|
this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
|
|
28535
29042
|
this.brokerManager.setEventBus(this.ctx.eventBus);
|
|
@@ -28872,6 +29379,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
28872
29379
|
];
|
|
28873
29380
|
}
|
|
28874
29381
|
async onShutdown() {
|
|
29382
|
+
this.loopStallMonitor?.stop();
|
|
29383
|
+
this.loopStallMonitor = null;
|
|
28875
29384
|
this.flushProbeSnapshots(Date.now(), true);
|
|
28876
29385
|
if (this.metricsSnapshotTimer) {
|
|
28877
29386
|
clearInterval(this.metricsSnapshotTimer);
|