@camstack/addon-pipeline 1.2.37 → 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-C5m4hC2K.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CjrKd4xX.mjs} +3 -3
- 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-WXFrgppm.mjs → hostInit-BFH17kSk.mjs} +3 -3
- 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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { n as __require, r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import { B as errMsg, G as createEvent, H as CAM_PROFILE_ORDER, J as makeProfileBrokerId, L as streamBrokerCapability, Q as parseProfileBrokerId, U as DeviceFeature, V as BaseAddon, W as DeviceType, X as nodePin, Y as makeSourceBrokerId, ct as object, dt as union, ft as EventCategory, g as addonWidgetsSourceCapability, it as discriminatedUnion, k as maskUrlCredentials, l as EncodeProfileSchema, lt as record, m as RingBuffer, nt as array, ot as literal, q as isEvent, rt as boolean, st as number, tt as _enum, ut as string, y as cameraStreamsCapability, z as webrtcSessionCapability } from "../dist-
|
|
2
|
+
import { B as errMsg, G as createEvent, H as CAM_PROFILE_ORDER, J as makeProfileBrokerId, L as streamBrokerCapability, Q as parseProfileBrokerId, U as DeviceFeature, V as BaseAddon, W as DeviceType, X as nodePin, Y as makeSourceBrokerId, ct as object, dt as union, ft as EventCategory, g as addonWidgetsSourceCapability, it as discriminatedUnion, k as maskUrlCredentials, l as EncodeProfileSchema, lt as record, m as RingBuffer, nt as array, ot as literal, q as isEvent, rt as boolean, st as number, tt as _enum, ut as string, y as cameraStreamsCapability, z as webrtcSessionCapability } from "../dist-COgGdFMv.mjs";
|
|
3
3
|
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
4
4
|
import { n as createFileDataPlaneHandler } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import * as os from "node:os";
|
|
7
|
-
import { networkInterfaces, tmpdir } from "node:os";
|
|
8
6
|
import { spawn } from "node:child_process";
|
|
9
7
|
import { pathToFileURL } from "node:url";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import { networkInterfaces, tmpdir } from "node:os";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path$1 from "node:path";
|
|
12
12
|
import path, { join } from "node:path";
|
|
@@ -3030,6 +3030,22 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3030
3030
|
/** Broker source-RTP pre-buffer accessor + one-shot replay guard. */
|
|
3031
3031
|
rtpBootstrap;
|
|
3032
3032
|
rtpBootstrapDone = false;
|
|
3033
|
+
/**
|
|
3034
|
+
* Answered-but-silent guard. The answer deliberately returns WITHOUT
|
|
3035
|
+
* awaiting source bring-up (bare trickle answer, background dial); the
|
|
3036
|
+
* failure shape that buys is a connected client staring at a black player
|
|
3037
|
+
* because the source never delivered. `sourceMediaSeen` flips on the FIRST
|
|
3038
|
+
* evidence of source media reaching this session — a source RTP packet
|
|
3039
|
+
* (`forwardSourceRtpVideo`, even before DTLS lets it transmit) or a video
|
|
3040
|
+
* NAL write (`writeVideoNals`, the AnnexB + transcode egress) — and the
|
|
3041
|
+
* watchdog armed at feed start warns ONCE if the budget elapses first.
|
|
3042
|
+
*/
|
|
3043
|
+
sourceMediaSeen = false;
|
|
3044
|
+
sourceSilenceTimer = null;
|
|
3045
|
+
/** Budget between "feeding started" (post-answer, post-DTLS) and the first
|
|
3046
|
+
* source media. Chosen above a healthy cold dial's worst case (~2–3 s) and
|
|
3047
|
+
* below the point a viewer gives up. */
|
|
3048
|
+
static SOURCE_SILENT_AFTER_ANSWER_MS = 5e3;
|
|
3033
3049
|
/** In-flight paced pre-buffer replay (instant-start bootstrap or PLI
|
|
3034
3050
|
* recovery). While active, live source-RTP packets are enqueued behind
|
|
3035
3051
|
* the replay tail to preserve packet order. Aborted on close/rebind. */
|
|
@@ -3495,8 +3511,10 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3495
3511
|
* compatible answer. This avoids werift transceiver direction issues.
|
|
3496
3512
|
*/
|
|
3497
3513
|
async handleOffer(clientOffer, opts) {
|
|
3514
|
+
const negotiateStart = Date.now();
|
|
3498
3515
|
const { werift, pcOptions } = await this.buildPcOptions();
|
|
3499
3516
|
this.pc = new werift.RTCPeerConnection(pcOptions);
|
|
3517
|
+
const pcSetupMs = Date.now() - negotiateStart;
|
|
3500
3518
|
this.wireControlChannel();
|
|
3501
3519
|
this.pc.iceConnectionStateChange.subscribe((state) => {
|
|
3502
3520
|
this.logger.info("ICE state", { meta: {
|
|
@@ -3529,7 +3547,9 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3529
3547
|
sdpMLineIndex: c.sdpMLineIndex ?? null
|
|
3530
3548
|
});
|
|
3531
3549
|
});
|
|
3550
|
+
const mdnsStart = Date.now();
|
|
3532
3551
|
const resolvedOfferSdp = await resolveMdnsCandidatesInSdp(clientOffer.sdp, this.logger, `session:${this.sessionId}`);
|
|
3552
|
+
const mdnsMs = Date.now() - mdnsStart;
|
|
3533
3553
|
const offerCandidates = resolvedOfferSdp.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("a=candidate:"));
|
|
3534
3554
|
if (this.debug) this.logger.info("client offer REMOTE ICE candidates", { meta: {
|
|
3535
3555
|
phase: "session",
|
|
@@ -3540,7 +3560,9 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3540
3560
|
candidates: offerCandidates
|
|
3541
3561
|
} });
|
|
3542
3562
|
const remoteDesc = new werift.RTCSessionDescription(resolvedOfferSdp, clientOffer.type);
|
|
3563
|
+
const setRemoteStart = Date.now();
|
|
3543
3564
|
await this.pc.setRemoteDescription(remoteDesc);
|
|
3565
|
+
const setRemoteMs = Date.now() - setRemoteStart;
|
|
3544
3566
|
const transceivers = this.pc.getTransceivers();
|
|
3545
3567
|
for (const t of transceivers) {
|
|
3546
3568
|
const kind = t.receiver?.track?.kind ?? t.kind;
|
|
@@ -3589,7 +3611,9 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3589
3611
|
const audioTransceiver = this.pc.addTransceiver(this.audioTrack, { direction: "sendonly" });
|
|
3590
3612
|
this.audioSender = audioTransceiver.sender;
|
|
3591
3613
|
}
|
|
3614
|
+
const createAnswerStart = Date.now();
|
|
3592
3615
|
const answerDesc = await this.pc.createAnswer();
|
|
3616
|
+
const createAnswerMs = Date.now() - createAnswerStart;
|
|
3593
3617
|
let finalSdp;
|
|
3594
3618
|
if (opts?.nonTrickle === true) {
|
|
3595
3619
|
await this.pc.setLocalDescription(answerDesc).catch((e) => {
|
|
@@ -3617,9 +3641,15 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3617
3641
|
this.logger.info("Codec negotiated (client-offer)", { meta: {
|
|
3618
3642
|
phase: "session",
|
|
3619
3643
|
sessionId: this.sessionId,
|
|
3644
|
+
deviceId: this.deviceId,
|
|
3620
3645
|
source: this.sourceCodec,
|
|
3621
3646
|
negotiated: this.negotiatedCodec,
|
|
3622
|
-
needsTranscode: this.needsTranscode
|
|
3647
|
+
needsTranscode: this.needsTranscode,
|
|
3648
|
+
negotiateMs: Date.now() - negotiateStart,
|
|
3649
|
+
pcSetupMs,
|
|
3650
|
+
mdnsMs,
|
|
3651
|
+
setRemoteMs,
|
|
3652
|
+
createAnswerMs
|
|
3623
3653
|
} });
|
|
3624
3654
|
if (this.debug) this.logger.info("Codec negotiation detail (client-offer)", { meta: {
|
|
3625
3655
|
phase: "session",
|
|
@@ -3769,6 +3799,10 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3769
3799
|
clearInterval(this.statsTimer);
|
|
3770
3800
|
this.statsTimer = null;
|
|
3771
3801
|
}
|
|
3802
|
+
if (this.sourceSilenceTimer) {
|
|
3803
|
+
clearTimeout(this.sourceSilenceTimer);
|
|
3804
|
+
this.sourceSilenceTimer = null;
|
|
3805
|
+
}
|
|
3772
3806
|
if (this.feedAbort) {
|
|
3773
3807
|
this.feedAbort.abort();
|
|
3774
3808
|
this.feedAbort = null;
|
|
@@ -3866,6 +3900,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3866
3900
|
*/
|
|
3867
3901
|
forwardSourceRtpVideo(rtpData) {
|
|
3868
3902
|
if (this.closed) return;
|
|
3903
|
+
this.sourceMediaSeen = true;
|
|
3869
3904
|
const codec = this.negotiatedCodec;
|
|
3870
3905
|
if (codec !== "H265" && codec !== "H264") return;
|
|
3871
3906
|
if (!this.videoSender || !_werift) return;
|
|
@@ -3877,6 +3912,12 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
3877
3912
|
this.startPacedRtpReplay(bootstrap, codec, "replayed source-RTP pre-buffer (instant start)");
|
|
3878
3913
|
return;
|
|
3879
3914
|
}
|
|
3915
|
+
this.logger.warn("instant-start pre-buffer empty — viewer waits for the next source IDR", { meta: {
|
|
3916
|
+
phase: "session",
|
|
3917
|
+
sessionId: this.sessionId,
|
|
3918
|
+
deviceId: this.deviceId,
|
|
3919
|
+
codec
|
|
3920
|
+
} });
|
|
3880
3921
|
}
|
|
3881
3922
|
if (this.activeRtpReplay?.active && this.activeRtpReplay.enqueue(rtpData)) return;
|
|
3882
3923
|
this.sendSourceRtpPacket(rtpData, codec);
|
|
@@ -4069,6 +4110,27 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
4069
4110
|
} });
|
|
4070
4111
|
this.writeVideoNals(this.lastKeyframeNals, this.sendRtpTimestamp(), this.negotiatedCodec);
|
|
4071
4112
|
}
|
|
4113
|
+
/**
|
|
4114
|
+
* Arm the answered-but-silent watchdog: if no source media has reached
|
|
4115
|
+
* this session `SOURCE_SILENT_AFTER_ANSWER_MS` after feeding started, one
|
|
4116
|
+
* named WARN fires. Idempotent; disarmed by `close()`. Public so the guard
|
|
4117
|
+
* is testable without standing up werift.
|
|
4118
|
+
*/
|
|
4119
|
+
armSourceSilenceWatchdog() {
|
|
4120
|
+
if (this.sourceSilenceTimer || this.closed) return;
|
|
4121
|
+
this.sourceSilenceTimer = setTimeout(() => {
|
|
4122
|
+
this.sourceSilenceTimer = null;
|
|
4123
|
+
if (this.closed || this.sourceMediaSeen) return;
|
|
4124
|
+
this.logger.warn("answered but NO source media reached the session — source dial stalled or camera silent", { meta: {
|
|
4125
|
+
phase: "session",
|
|
4126
|
+
sessionId: this.sessionId,
|
|
4127
|
+
deviceId: this.deviceId,
|
|
4128
|
+
budgetMs: AdaptiveSession.SOURCE_SILENT_AFTER_ANSWER_MS,
|
|
4129
|
+
needsTranscode: this.needsTranscode
|
|
4130
|
+
} });
|
|
4131
|
+
}, AdaptiveSession.SOURCE_SILENT_AFTER_ANSWER_MS);
|
|
4132
|
+
this.sourceSilenceTimer.unref?.();
|
|
4133
|
+
}
|
|
4072
4134
|
startFeedingFrames() {
|
|
4073
4135
|
this.feedAbort = new AbortController();
|
|
4074
4136
|
const { signal } = this.feedAbort;
|
|
@@ -4078,6 +4140,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
4078
4140
|
dtls: this.videoSender?.dtlsTransport?.state ?? "unknown",
|
|
4079
4141
|
needsTranscode: this.needsTranscode
|
|
4080
4142
|
} });
|
|
4143
|
+
this.armSourceSilenceWatchdog();
|
|
4081
4144
|
if (this.needsTranscode) this.startTranscodeFeed(signal);
|
|
4082
4145
|
else this.startDirectFeed(signal);
|
|
4083
4146
|
}
|
|
@@ -4653,6 +4716,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
4653
4716
|
});
|
|
4654
4717
|
}
|
|
4655
4718
|
writeVideoNals(nals, rtpTs, codec) {
|
|
4719
|
+
this.sourceMediaSeen = true;
|
|
4656
4720
|
if (!this.videoSender || !_werift) {
|
|
4657
4721
|
if (this.rtpLogCounter === 0) {
|
|
4658
4722
|
this.logger.warn("writeVideoNals: no sender", { meta: {
|
|
@@ -9730,6 +9794,21 @@ var DecoderSessionProxy = class {
|
|
|
9730
9794
|
this.api = api;
|
|
9731
9795
|
this.sessionId = sessionId;
|
|
9732
9796
|
}
|
|
9797
|
+
/**
|
|
9798
|
+
* Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
|
|
9799
|
+
* regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
|
|
9800
|
+
* `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
|
|
9801
|
+
* is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
|
|
9802
|
+
* peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
|
|
9803
|
+
* stub, an old decoder that predates `waitMs`, a test fake — would otherwise
|
|
9804
|
+
* turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
|
|
9805
|
+
* that pins the CPU, starves every timer in the process, and allocates until
|
|
9806
|
+
* the heap dies (observed: a 4 GB OOM in under 15 s).
|
|
9807
|
+
*/
|
|
9808
|
+
async paceEmptyDrain(drainStartedAt) {
|
|
9809
|
+
const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
|
|
9810
|
+
if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
|
|
9811
|
+
}
|
|
9733
9812
|
async pushPacket(packet) {
|
|
9734
9813
|
await this.api.pushPacket({
|
|
9735
9814
|
sessionId: this.sessionId,
|
|
@@ -9745,12 +9824,14 @@ var DecoderSessionProxy = class {
|
|
|
9745
9824
|
async startPolling(onFrame) {
|
|
9746
9825
|
this.polling = true;
|
|
9747
9826
|
while (this.polling) {
|
|
9827
|
+
const drainStartedAt = Date.now();
|
|
9748
9828
|
const frames = await this.api.pullFrames({
|
|
9749
9829
|
sessionId: this.sessionId,
|
|
9750
9830
|
maxCount: 4,
|
|
9751
9831
|
waitMs: POLL_WAIT_MS
|
|
9752
9832
|
});
|
|
9753
9833
|
for (const frame of frames) onFrame(frame);
|
|
9834
|
+
if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
|
|
9754
9835
|
}
|
|
9755
9836
|
}
|
|
9756
9837
|
/**
|
|
@@ -9768,12 +9849,14 @@ var DecoderSessionProxy = class {
|
|
|
9768
9849
|
async startHandlePolling(onHandle) {
|
|
9769
9850
|
this.polling = true;
|
|
9770
9851
|
while (this.polling) try {
|
|
9852
|
+
const drainStartedAt = Date.now();
|
|
9771
9853
|
const handles = await this.api.pullHandles({
|
|
9772
9854
|
sessionId: this.sessionId,
|
|
9773
9855
|
maxCount: 4,
|
|
9774
9856
|
waitMs: POLL_WAIT_MS
|
|
9775
9857
|
});
|
|
9776
9858
|
for (const handle of handles) onHandle(handle);
|
|
9859
|
+
if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
|
|
9777
9860
|
} catch (err) {
|
|
9778
9861
|
this.polling = false;
|
|
9779
9862
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -17779,6 +17862,71 @@ function withSourceResolution(consumerDefault, probedSource) {
|
|
|
17779
17862
|
};
|
|
17780
17863
|
}
|
|
17781
17864
|
//#endregion
|
|
17865
|
+
//#region src/stream-broker/webrtc/broker-linger-registry.ts
|
|
17866
|
+
/** Matches the observed reopen gaps (1–2 min) with headroom; well under the
|
|
17867
|
+
* "operator walked away" scale where warmth would be waste. */
|
|
17868
|
+
var DEFAULT_LINGER_MS = 3 * 6e4;
|
|
17869
|
+
var BrokerLingerRegistry = class {
|
|
17870
|
+
lingerMs;
|
|
17871
|
+
subscribe;
|
|
17872
|
+
holds = /* @__PURE__ */ new Map();
|
|
17873
|
+
constructor(options) {
|
|
17874
|
+
this.lingerMs = options.lingerMs ?? DEFAULT_LINGER_MS;
|
|
17875
|
+
this.subscribe = options.subscribe;
|
|
17876
|
+
}
|
|
17877
|
+
/**
|
|
17878
|
+
* Start (or restart) the linger window for a broker that just lost its
|
|
17879
|
+
* last session. Re-holding an already-held broker re-arms the timer
|
|
17880
|
+
* without stacking a second ghost subscription.
|
|
17881
|
+
*/
|
|
17882
|
+
hold(brokerId) {
|
|
17883
|
+
const existing = this.holds.get(brokerId);
|
|
17884
|
+
if (existing) {
|
|
17885
|
+
clearTimeout(existing.timer);
|
|
17886
|
+
existing.timer = this.armTimer(brokerId);
|
|
17887
|
+
return;
|
|
17888
|
+
}
|
|
17889
|
+
const unsubscribe = this.subscribe(brokerId);
|
|
17890
|
+
if (unsubscribe === null) return;
|
|
17891
|
+
this.holds.set(brokerId, {
|
|
17892
|
+
unsubscribe,
|
|
17893
|
+
timer: this.armTimer(brokerId)
|
|
17894
|
+
});
|
|
17895
|
+
}
|
|
17896
|
+
/** Release the hold NOW — a new session attached, the real subscription
|
|
17897
|
+
* carries the demand from here. No-op when nothing is held. */
|
|
17898
|
+
cancel(brokerId) {
|
|
17899
|
+
const hold = this.holds.get(brokerId);
|
|
17900
|
+
if (!hold) return;
|
|
17901
|
+
clearTimeout(hold.timer);
|
|
17902
|
+
this.holds.delete(brokerId);
|
|
17903
|
+
this.release(hold);
|
|
17904
|
+
}
|
|
17905
|
+
/** Release every hold (server shutdown). */
|
|
17906
|
+
stop() {
|
|
17907
|
+
for (const [brokerId, hold] of this.holds) {
|
|
17908
|
+
clearTimeout(hold.timer);
|
|
17909
|
+
this.holds.delete(brokerId);
|
|
17910
|
+
this.release(hold);
|
|
17911
|
+
}
|
|
17912
|
+
}
|
|
17913
|
+
armTimer(brokerId) {
|
|
17914
|
+
const timer = setTimeout(() => {
|
|
17915
|
+
const hold = this.holds.get(brokerId);
|
|
17916
|
+
if (!hold) return;
|
|
17917
|
+
this.holds.delete(brokerId);
|
|
17918
|
+
this.release(hold);
|
|
17919
|
+
}, this.lingerMs);
|
|
17920
|
+
timer.unref?.();
|
|
17921
|
+
return timer;
|
|
17922
|
+
}
|
|
17923
|
+
release(hold) {
|
|
17924
|
+
try {
|
|
17925
|
+
hold.unsubscribe();
|
|
17926
|
+
} catch {}
|
|
17927
|
+
}
|
|
17928
|
+
};
|
|
17929
|
+
//#endregion
|
|
17782
17930
|
//#region src/stream-broker/webrtc/loss-tracker.ts
|
|
17783
17931
|
/** Delta between two RTCP extended (32-bit) highest-sequence values, with
|
|
17784
17932
|
* 32-bit wraparound handling. */
|
|
@@ -17827,6 +17975,24 @@ var LossTracker = class {
|
|
|
17827
17975
|
if (now < this.resetUntil) return false;
|
|
17828
17976
|
return this.lossRatio() > this.config.minFailPercent;
|
|
17829
17977
|
}
|
|
17978
|
+
/**
|
|
17979
|
+
* True when the window holds enough recent receiver feedback for a
|
|
17980
|
+
* "healthy" verdict to MEAN anything.
|
|
17981
|
+
*
|
|
17982
|
+
* `lossRatio()` returns 0 with fewer than two in-window samples, so
|
|
17983
|
+
* `isFailing` is false for a session that has produced NO feedback at all —
|
|
17984
|
+
* a link so bad that RTCP never arrives reads identically to a perfect one.
|
|
17985
|
+
* A downgrade may act on that (0 loss ⇒ don't downgrade, which is safe); an
|
|
17986
|
+
* UPGRADE must not, because it spends bandwidth the session has never been
|
|
17987
|
+
* shown to have. Silence is not health.
|
|
17988
|
+
*/
|
|
17989
|
+
hasEvidence(now) {
|
|
17990
|
+
if (now < this.resetUntil) return false;
|
|
17991
|
+
const cutoff = now - this.config.analyzeWindowMs;
|
|
17992
|
+
let inWindow = 0;
|
|
17993
|
+
for (const s of this.samples) if (s.time > cutoff) inWindow += 1;
|
|
17994
|
+
return inWindow >= 2;
|
|
17995
|
+
}
|
|
17830
17996
|
};
|
|
17831
17997
|
//#endregion
|
|
17832
17998
|
//#region src/stream-broker/webrtc/adaptive-controller.ts
|
|
@@ -17835,8 +18001,17 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
|
|
|
17835
18001
|
* Ladder state machine for an adaptive WebRTC session. Fed loss samples
|
|
17836
18002
|
* (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
|
|
17837
18003
|
* Downgrade order: tier high→mid→low, then MTU reduction at low, then
|
|
17838
|
-
* transcode-down.
|
|
17839
|
-
*
|
|
18004
|
+
* transcode-down. Side effects run through injected callbacks (testable
|
|
18005
|
+
* without werift).
|
|
18006
|
+
*
|
|
18007
|
+
* An UPGRADE requires all three of:
|
|
18008
|
+
* 1. the session clock — measured from the FIRST tick, not from the unix
|
|
18009
|
+
* epoch — showing `upgradeHealthyMs` without a failing verdict;
|
|
18010
|
+
* 2. actual receiver feedback in the window (`LossTracker.hasEvidence`) —
|
|
18011
|
+
* a session that has produced no RTCP at all is not "healthy", it is
|
|
18012
|
+
* silent, and climbing on silence spends bandwidth never shown to exist;
|
|
18013
|
+
* 3. the target tier's re-attempt backoff, which doubles per measured
|
|
18014
|
+
* failure of that tier in this session.
|
|
17840
18015
|
*/
|
|
17841
18016
|
var AdaptiveController = class {
|
|
17842
18017
|
opts;
|
|
@@ -17846,27 +18021,86 @@ var AdaptiveController = class {
|
|
|
17846
18021
|
transcodeEngaged = false;
|
|
17847
18022
|
lastChangeAt = 0;
|
|
17848
18023
|
healthySince = 0;
|
|
18024
|
+
/**
|
|
18025
|
+
* Whether the session clock has been armed by the first evaluation. The
|
|
18026
|
+
* clock is `Date.now()`, so leaving `healthySince` / `lastChangeAt` at 0
|
|
18027
|
+
* would read as "healthy since 1970" and hand out a free upgrade on the
|
|
18028
|
+
* very first tick, before a single loss sample had been seen. A boolean
|
|
18029
|
+
* (not a `startedAt === 0` sentinel) because 0 is a legitimate clock value.
|
|
18030
|
+
*/
|
|
18031
|
+
armed = false;
|
|
18032
|
+
failures;
|
|
17849
18033
|
constructor(opts) {
|
|
17850
18034
|
this.opts = opts;
|
|
17851
18035
|
this.tracker = new LossTracker(opts.config);
|
|
17852
18036
|
this.tier = opts.initialTier;
|
|
18037
|
+
this.failures = new Map(opts.priorFailures ?? []);
|
|
17853
18038
|
}
|
|
17854
18039
|
get currentTier() {
|
|
17855
18040
|
return this.tier;
|
|
17856
18041
|
}
|
|
18042
|
+
/**
|
|
18043
|
+
* Per-tier measured-failure counts, INCLUDING those inherited from earlier
|
|
18044
|
+
* rungs of the same viewer session. The server snapshots this into the
|
|
18045
|
+
* adaptive intent so the session's next controller inherits it.
|
|
18046
|
+
*/
|
|
18047
|
+
get failureCounts() {
|
|
18048
|
+
return this.failures;
|
|
18049
|
+
}
|
|
17857
18050
|
onLoss(sample, now) {
|
|
17858
18051
|
this.tracker.track(sample, now);
|
|
17859
18052
|
}
|
|
18053
|
+
/**
|
|
18054
|
+
* Freeze the ladder for this evaluation instead of ticking it.
|
|
18055
|
+
*
|
|
18056
|
+
* Called while the session's track is NOT carrying the live source — D42
|
|
18057
|
+
* put recorded playback and scrub on the same track with a moving source
|
|
18058
|
+
* pointer, and in those modes the live feed is gated, so receiver feedback
|
|
18059
|
+
* measures the recorded feeder, not the camera tier. Acting on it is
|
|
18060
|
+
* incoherent in both directions: a downgrade cannot shrink a file, an
|
|
18061
|
+
* upgrade cannot enlarge one, and EITHER destroys the feeder (a tier switch
|
|
18062
|
+
* is a close-and-recreate re-offer) — dropping the viewer back to live at
|
|
18063
|
+
* the wrong moment with nothing to restore the pointer.
|
|
18064
|
+
*
|
|
18065
|
+
* Holds the climb clock so time spent in recorded modes can never
|
|
18066
|
+
* accumulate into an upgrade on the return to live.
|
|
18067
|
+
*/
|
|
18068
|
+
suspend(now) {
|
|
18069
|
+
if (!this.armed) {
|
|
18070
|
+
this.armed = true;
|
|
18071
|
+
this.lastChangeAt = now;
|
|
18072
|
+
}
|
|
18073
|
+
this.healthySince = now;
|
|
18074
|
+
}
|
|
17860
18075
|
tick(now) {
|
|
18076
|
+
if (!this.armed) {
|
|
18077
|
+
this.armed = true;
|
|
18078
|
+
this.healthySince = now;
|
|
18079
|
+
this.lastChangeAt = now;
|
|
18080
|
+
}
|
|
17861
18081
|
if (this.tracker.isFailing(now)) {
|
|
17862
18082
|
this.healthySince = now;
|
|
17863
18083
|
this.handleFailing(now);
|
|
17864
18084
|
return;
|
|
17865
18085
|
}
|
|
18086
|
+
if (!this.tracker.hasEvidence(now)) {
|
|
18087
|
+
this.healthySince = now;
|
|
18088
|
+
return;
|
|
18089
|
+
}
|
|
17866
18090
|
this.handleHealthy(now);
|
|
17867
18091
|
}
|
|
17868
18092
|
canUpgrade(now) {
|
|
17869
|
-
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs
|
|
18093
|
+
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
|
|
18094
|
+
}
|
|
18095
|
+
/**
|
|
18096
|
+
* How long the link must read healthy before `tier` may be re-attempted.
|
|
18097
|
+
* Doubles per measured failure of that tier in this session, capped at
|
|
18098
|
+
* `maxUpgradeBackoffMs`.
|
|
18099
|
+
*/
|
|
18100
|
+
requiredHealthyMs(tier) {
|
|
18101
|
+
const failures = this.failures.get(tier) ?? 0;
|
|
18102
|
+
if (failures <= 0) return this.opts.config.upgradeHealthyMs;
|
|
18103
|
+
return Math.min(this.opts.config.upgradeHealthyMs * 2 ** failures, this.opts.config.maxUpgradeBackoffMs);
|
|
17870
18104
|
}
|
|
17871
18105
|
commitChange(now) {
|
|
17872
18106
|
this.lastChangeAt = now;
|
|
@@ -17875,6 +18109,8 @@ var AdaptiveController = class {
|
|
|
17875
18109
|
handleFailing(now) {
|
|
17876
18110
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
17877
18111
|
if (idx < TIER_ORDER.length - 1) {
|
|
18112
|
+
const failed = this.tier;
|
|
18113
|
+
this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
|
|
17878
18114
|
this.tier = TIER_ORDER[idx + 1];
|
|
17879
18115
|
this.opts.onSwitchTier(this.tier);
|
|
17880
18116
|
this.commitChange(now);
|
|
@@ -17893,17 +18129,17 @@ var AdaptiveController = class {
|
|
|
17893
18129
|
}
|
|
17894
18130
|
}
|
|
17895
18131
|
handleHealthy(now) {
|
|
17896
|
-
if (now - this.healthySince < this.opts.config.upgradeHealthyMs) return;
|
|
17897
|
-
if (!this.canUpgrade(now)) return;
|
|
17898
18132
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
17899
|
-
if (idx
|
|
17900
|
-
|
|
17901
|
-
|
|
17902
|
-
|
|
17903
|
-
|
|
17904
|
-
|
|
17905
|
-
|
|
17906
|
-
|
|
18133
|
+
if (idx <= 0) return;
|
|
18134
|
+
const next = TIER_ORDER[idx - 1];
|
|
18135
|
+
if (now - this.healthySince < this.requiredHealthyMs(next)) return;
|
|
18136
|
+
if (!this.canUpgrade(now)) return;
|
|
18137
|
+
this.tier = next;
|
|
18138
|
+
this.mtuReduced = false;
|
|
18139
|
+
this.transcodeEngaged = false;
|
|
18140
|
+
this.opts.onSwitchTier(next);
|
|
18141
|
+
this.commitChange(now);
|
|
18142
|
+
this.healthySince = now;
|
|
17907
18143
|
}
|
|
17908
18144
|
};
|
|
17909
18145
|
//#endregion
|
|
@@ -17928,6 +18164,7 @@ var ControlMessageSchema = discriminatedUnion("t", [
|
|
|
17928
18164
|
t: literal("scrubTo"),
|
|
17929
18165
|
epoch: number()
|
|
17930
18166
|
}),
|
|
18167
|
+
object({ t: literal("scrubAck") }),
|
|
17931
18168
|
object({
|
|
17932
18169
|
t: literal("setRate"),
|
|
17933
18170
|
rate: number().min(0).max(4)
|
|
@@ -18101,6 +18338,74 @@ var SCRUB_CACHE_MAX = 8;
|
|
|
18101
18338
|
* sustained direction, so a jittery/reversing drag never wastes the slot.
|
|
18102
18339
|
*/
|
|
18103
18340
|
var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
|
|
18341
|
+
/**
|
|
18342
|
+
* Max forward TARGET delta (ms of timeline, between consecutive drag targets)
|
|
18343
|
+
* that SWEEPS predicted frames instead of jumping. A steady forward drag is
|
|
18344
|
+
* playback with a moving target: once a keyframe decoded, the predicted frames
|
|
18345
|
+
* after it decode in sequence at a fraction of an IDR's bytes — that is the
|
|
18346
|
+
* continuous flow. Beyond this window (a fling), or backwards, a jump to an
|
|
18347
|
+
* independently-decodable keyframe is cheaper than the burst; and because a
|
|
18348
|
+
* thin pipe slows the ack-gated ticks down, per-tick deltas GROW under
|
|
18349
|
+
* congestion, so degradation to jump-only is emergent, and visible per push
|
|
18350
|
+
* (`mode` on every scrub-feeder:pushed line).
|
|
18351
|
+
*/
|
|
18352
|
+
var SCRUB_SWEEP_WINDOW_MS = 4e3;
|
|
18353
|
+
/**
|
|
18354
|
+
* Receiver-credit cap for scrub pushes: at most this many keyframes may be
|
|
18355
|
+
* OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
|
|
18356
|
+
* the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
|
|
18357
|
+
* a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
|
|
18358
|
+
* floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
|
|
18359
|
+
* Backpressure is the receiver's: a push spends a credit, a rendered frame
|
|
18360
|
+
* restores one, and with zero credit the newest target PARKS on
|
|
18361
|
+
* `scrubPendingEpochMs` until an ack arrives — a timer never releases work.
|
|
18362
|
+
*/
|
|
18363
|
+
var SCRUB_CREDIT_CAP = 2;
|
|
18364
|
+
/**
|
|
18365
|
+
* How far a `gap` locate may be SNAPPED onto real footage.
|
|
18366
|
+
*
|
|
18367
|
+
* A snap is a CORRECTION — the playhead landed a moment outside coverage and
|
|
18368
|
+
* the operator plainly means the footage beside it — not a SEARCH for wherever
|
|
18369
|
+
* footage happens to exist. The distinction is not cosmetic, because the index
|
|
18370
|
+
* that answers `locate` cannot tell "there is no footage here" apart from "I
|
|
18371
|
+
* have not walked this far back yet": it is rebuilt by walking the archive on
|
|
18372
|
+
* every recorder start, and that walk takes minutes.
|
|
18373
|
+
*
|
|
18374
|
+
* Production 2026-08-02: the recorder had restarted 9 minutes earlier, so
|
|
18375
|
+
* `locate` answered `gap` for the whole timeline with a `nearestEdgeMs` 6
|
|
18376
|
+
* minutes to 23 HOURS away. Unbounded snapping turned every seek and every drag
|
|
18377
|
+
* tick into a segment read against the addon that was busy re-walking, each one
|
|
18378
|
+
* blew the 8 s budget, and both surfaces died. Bounded, all of those answer
|
|
18379
|
+
* `gap` for free — exactly as they did before snapping existed.
|
|
18380
|
+
*
|
|
18381
|
+
* 120 s covers the real cases by a wide margin: an inter-segment crack is
|
|
18382
|
+
* 11–17 ms, and the dead band between two recording bands measured 48 s. Beyond
|
|
18383
|
+
* that the server is answering a question the viewer did not ask; the viewer
|
|
18384
|
+
* holds the coverage ranges and can offer "jump to nearest footage" as an
|
|
18385
|
+
* action instead.
|
|
18386
|
+
*/
|
|
18387
|
+
var SNAP_MAX_DISTANCE_MS = 12e4;
|
|
18388
|
+
/**
|
|
18389
|
+
* Decide where a `gap` locate should snap: forward to the next footage edge,
|
|
18390
|
+
* else backward onto the last ms of the footage behind (`prevEndMs − 1`, which
|
|
18391
|
+
* also hops the 11–17 ms inter-segment cracks). Rejects a candidate further than
|
|
18392
|
+
* {@link SNAP_MAX_DISTANCE_MS} so a partially-walked index cannot teleport the
|
|
18393
|
+
* cursor — and so rejecting costs NO segment I/O at all.
|
|
18394
|
+
*/
|
|
18395
|
+
function resolveSnapTarget(loc, requestedMs) {
|
|
18396
|
+
const snapMs = loc.nearestEdgeMs ?? (typeof loc.prevEndMs === "number" ? loc.prevEndMs - 1 : null);
|
|
18397
|
+
if (snapMs === null) return { kind: "none" };
|
|
18398
|
+
const distanceMs = Math.abs(snapMs - requestedMs);
|
|
18399
|
+
if (distanceMs > SNAP_MAX_DISTANCE_MS) return {
|
|
18400
|
+
kind: "too-far",
|
|
18401
|
+
snapMs,
|
|
18402
|
+
distanceMs
|
|
18403
|
+
};
|
|
18404
|
+
return {
|
|
18405
|
+
kind: "snap",
|
|
18406
|
+
snapMs
|
|
18407
|
+
};
|
|
18408
|
+
}
|
|
18104
18409
|
var RecordedFeeder = class {
|
|
18105
18410
|
deps;
|
|
18106
18411
|
clock = new MonotonicClock();
|
|
@@ -18143,6 +18448,16 @@ var RecordedFeeder = class {
|
|
|
18143
18448
|
*/
|
|
18144
18449
|
scrubbing = false;
|
|
18145
18450
|
scrubToken = 0;
|
|
18451
|
+
/** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
|
|
18452
|
+
scrubCredit = SCRUB_CREDIT_CAP;
|
|
18453
|
+
/**
|
|
18454
|
+
* Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
|
|
18455
|
+
* `1`; a sweep enqueues however many predicted frames it put on the wire. Each
|
|
18456
|
+
* `scrubAck` settles one frame off the head, and the credit that push spent
|
|
18457
|
+
* comes back only when the head reaches zero — so a sweep cannot be refunded
|
|
18458
|
+
* once per frame for the single credit it cost.
|
|
18459
|
+
*/
|
|
18460
|
+
scrubOutstanding = [];
|
|
18146
18461
|
/** The newest un-processed drag target (null when the tick cycle has drained). */
|
|
18147
18462
|
scrubPendingEpochMs = null;
|
|
18148
18463
|
/**
|
|
@@ -18173,6 +18488,15 @@ var RecordedFeeder = class {
|
|
|
18173
18488
|
* sign of `(newTarget − this)` is the drag direction that steers prefetch.
|
|
18174
18489
|
*/
|
|
18175
18490
|
scrubPrevTarget = null;
|
|
18491
|
+
/** The last AU actually pushed this drag — the sweep-continuity cursor
|
|
18492
|
+
* (which fragment, and how deep into its AU list). Null until a jump lands. */
|
|
18493
|
+
scrubCursor = null;
|
|
18494
|
+
/** Per-drag mode accounting for the `scrub-feeder:drag-end` summary. */
|
|
18495
|
+
scrubJumps = 0;
|
|
18496
|
+
scrubSweeps = 0;
|
|
18497
|
+
scrubAusPushed = 0;
|
|
18498
|
+
/** Once-per-drag guard: a dead-zone drag ticks fast; one line says it all. */
|
|
18499
|
+
scrubNoFootageLogged = false;
|
|
18176
18500
|
/**
|
|
18177
18501
|
* Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
|
|
18178
18502
|
* − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
|
|
@@ -18210,9 +18534,19 @@ var RecordedFeeder = class {
|
|
|
18210
18534
|
async seek(epochMs) {
|
|
18211
18535
|
const token = ++this.seekToken;
|
|
18212
18536
|
if (this.scrubbing) {
|
|
18537
|
+
this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
18538
|
+
jumps: this.scrubJumps,
|
|
18539
|
+
sweeps: this.scrubSweeps,
|
|
18540
|
+
aus: this.scrubAusPushed
|
|
18541
|
+
});
|
|
18213
18542
|
this.scrubbing = false;
|
|
18214
18543
|
this.scrubToken++;
|
|
18215
18544
|
this.scrubSegment = null;
|
|
18545
|
+
this.scrubCursor = null;
|
|
18546
|
+
this.scrubJumps = 0;
|
|
18547
|
+
this.scrubSweeps = 0;
|
|
18548
|
+
this.scrubAusPushed = 0;
|
|
18549
|
+
this.scrubOutstanding = [];
|
|
18216
18550
|
this.scrubCache.clear();
|
|
18217
18551
|
this.scrubPrevTarget = null;
|
|
18218
18552
|
this.scrubDirRun = 0;
|
|
@@ -18223,29 +18557,67 @@ var RecordedFeeder = class {
|
|
|
18223
18557
|
}
|
|
18224
18558
|
this.advanceToken++;
|
|
18225
18559
|
this.inFlightSeekEpochMs = epochMs;
|
|
18560
|
+
let snapInfo = null;
|
|
18226
18561
|
try {
|
|
18227
18562
|
this.deps.onState("loading");
|
|
18228
18563
|
if (this.clockHasAdvanced) {
|
|
18229
18564
|
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18230
18565
|
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18231
18566
|
}
|
|
18232
|
-
|
|
18567
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
18233
18568
|
deviceId: this.deps.deviceId,
|
|
18234
18569
|
profile: this.deps.profile,
|
|
18235
18570
|
epochMs
|
|
18236
18571
|
}));
|
|
18237
18572
|
if (this.disposed || token !== this.seekToken) return;
|
|
18573
|
+
let landMs = epochMs;
|
|
18238
18574
|
if (loc.kind === "gap") {
|
|
18239
|
-
|
|
18240
|
-
|
|
18241
|
-
|
|
18242
|
-
|
|
18243
|
-
|
|
18575
|
+
const decision = resolveSnapTarget(loc, epochMs);
|
|
18576
|
+
if (decision.kind !== "snap") {
|
|
18577
|
+
if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
|
|
18578
|
+
requestedMs: epochMs,
|
|
18579
|
+
nearestMs: decision.snapMs,
|
|
18580
|
+
distanceMs: decision.distanceMs
|
|
18581
|
+
});
|
|
18582
|
+
else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
|
|
18583
|
+
this.currentSegment = null;
|
|
18584
|
+
this.lastFedVideoIdx = -1;
|
|
18585
|
+
this.stopPositionReports();
|
|
18586
|
+
this.deps.onState("gap");
|
|
18587
|
+
return;
|
|
18588
|
+
}
|
|
18589
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
18590
|
+
deviceId: this.deps.deviceId,
|
|
18591
|
+
profile: this.deps.profile,
|
|
18592
|
+
epochMs: decision.snapMs
|
|
18593
|
+
}));
|
|
18594
|
+
if (this.disposed || token !== this.seekToken) return;
|
|
18595
|
+
if (snapped.kind !== "segment") {
|
|
18596
|
+
this.deps.onLog?.("recorded:seek-no-footage", {
|
|
18597
|
+
requestedMs: epochMs,
|
|
18598
|
+
snappedMs: decision.snapMs
|
|
18599
|
+
});
|
|
18600
|
+
this.currentSegment = null;
|
|
18601
|
+
this.lastFedVideoIdx = -1;
|
|
18602
|
+
this.stopPositionReports();
|
|
18603
|
+
this.deps.onState("gap");
|
|
18604
|
+
return;
|
|
18605
|
+
}
|
|
18606
|
+
this.deps.onLog?.("recorded:seek-snapped", {
|
|
18607
|
+
requestedMs: epochMs,
|
|
18608
|
+
snappedMs: decision.snapMs
|
|
18609
|
+
});
|
|
18610
|
+
loc = snapped;
|
|
18611
|
+
landMs = decision.snapMs;
|
|
18612
|
+
snapInfo = {
|
|
18613
|
+
requestedMs: epochMs,
|
|
18614
|
+
snappedMs: decision.snapMs
|
|
18615
|
+
};
|
|
18244
18616
|
}
|
|
18245
18617
|
this.cursorMs = loc.startMs;
|
|
18246
18618
|
const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
|
|
18247
18619
|
if (this.disposed || token !== this.seekToken || loaded === null) return;
|
|
18248
|
-
this.feedPrefix(loc, loaded.video,
|
|
18620
|
+
this.feedPrefix(loc, loaded.video, landMs);
|
|
18249
18621
|
if (this.rate === 0) {
|
|
18250
18622
|
this.deps.onState("paused");
|
|
18251
18623
|
this.deps.onPosition(this.cursorMs);
|
|
@@ -18265,9 +18637,20 @@ var RecordedFeeder = class {
|
|
|
18265
18637
|
},
|
|
18266
18638
|
resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
|
|
18267
18639
|
});
|
|
18268
|
-
} catch {
|
|
18640
|
+
} catch (err) {
|
|
18269
18641
|
if (!this.disposed && token === this.seekToken) {
|
|
18270
18642
|
this.stopPositionReports();
|
|
18643
|
+
if (snapInfo !== null) {
|
|
18644
|
+
this.deps.onLog?.("recorded:seek-snap-load-failed", {
|
|
18645
|
+
requestedMs: snapInfo.requestedMs,
|
|
18646
|
+
snappedMs: snapInfo.snappedMs,
|
|
18647
|
+
error: errMsg(err)
|
|
18648
|
+
});
|
|
18649
|
+
this.currentSegment = null;
|
|
18650
|
+
this.lastFedVideoIdx = -1;
|
|
18651
|
+
this.deps.onState("gap");
|
|
18652
|
+
return;
|
|
18653
|
+
}
|
|
18271
18654
|
this.deps.onState("error");
|
|
18272
18655
|
}
|
|
18273
18656
|
} finally {
|
|
@@ -18322,8 +18705,15 @@ var RecordedFeeder = class {
|
|
|
18322
18705
|
this.scrubToken++;
|
|
18323
18706
|
this.scrubPendingEpochMs = null;
|
|
18324
18707
|
this.scrubSegment = null;
|
|
18708
|
+
this.scrubCredit = SCRUB_CREDIT_CAP;
|
|
18325
18709
|
this.scrubPrevTarget = null;
|
|
18326
18710
|
this.scrubDirRun = 0;
|
|
18711
|
+
this.scrubCursor = null;
|
|
18712
|
+
this.scrubJumps = 0;
|
|
18713
|
+
this.scrubSweeps = 0;
|
|
18714
|
+
this.scrubAusPushed = 0;
|
|
18715
|
+
this.scrubNoFootageLogged = false;
|
|
18716
|
+
this.scrubOutstanding = [];
|
|
18327
18717
|
if (this.scrubTimer) {
|
|
18328
18718
|
clearTimeout(this.scrubTimer);
|
|
18329
18719
|
this.scrubTimer = null;
|
|
@@ -18343,6 +18733,24 @@ var RecordedFeeder = class {
|
|
|
18343
18733
|
if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
18344
18734
|
}
|
|
18345
18735
|
/**
|
|
18736
|
+
* Receiver credit: the viewer RENDERED a scrub frame. Restores one push
|
|
18737
|
+
* credit (capped — over-acking never banks a burst) and, when a target sat
|
|
18738
|
+
* parked on an exhausted budget, resumes the tick. This — not any timer — is
|
|
18739
|
+
* what paces scrub pushes to what the link actually delivers.
|
|
18740
|
+
*/
|
|
18741
|
+
scrubAck() {
|
|
18742
|
+
if (this.disposed) return;
|
|
18743
|
+
const head = this.scrubOutstanding[0];
|
|
18744
|
+
if (head === void 0) return;
|
|
18745
|
+
if (head > 1) {
|
|
18746
|
+
this.scrubOutstanding[0] = head - 1;
|
|
18747
|
+
return;
|
|
18748
|
+
}
|
|
18749
|
+
this.scrubOutstanding.shift();
|
|
18750
|
+
this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
|
|
18751
|
+
if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
18752
|
+
}
|
|
18753
|
+
/**
|
|
18346
18754
|
* One coalesced feeder-scrub push. Captures the newest pending target + the
|
|
18347
18755
|
* current mode generation, resolves the covering fragment (reusing the memo'd
|
|
18348
18756
|
* one when the target stays inside it), picks the nearest keyframe ≤ the
|
|
@@ -18353,25 +18761,68 @@ var RecordedFeeder = class {
|
|
|
18353
18761
|
*/
|
|
18354
18762
|
async scrubTick() {
|
|
18355
18763
|
const gen = this.scrubToken;
|
|
18764
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) {
|
|
18765
|
+
this.scrubTimer = null;
|
|
18766
|
+
return;
|
|
18767
|
+
}
|
|
18768
|
+
if (this.scrubCredit <= 0) {
|
|
18769
|
+
this.scrubTimer = null;
|
|
18770
|
+
this.deps.onLog?.("scrub-feeder:parked", { pendingEpochMs: this.scrubPendingEpochMs });
|
|
18771
|
+
return;
|
|
18772
|
+
}
|
|
18356
18773
|
const target = this.scrubPendingEpochMs;
|
|
18357
18774
|
this.scrubPendingEpochMs = null;
|
|
18358
|
-
if (
|
|
18775
|
+
if (target === null) {
|
|
18359
18776
|
this.scrubTimer = null;
|
|
18360
18777
|
return;
|
|
18361
18778
|
}
|
|
18362
18779
|
try {
|
|
18363
|
-
|
|
18780
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
18364
18781
|
deviceId: this.deps.deviceId,
|
|
18365
18782
|
profile: this.deps.profile,
|
|
18366
18783
|
epochMs: target
|
|
18367
18784
|
}));
|
|
18368
18785
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18369
|
-
|
|
18786
|
+
let effTarget = target;
|
|
18787
|
+
if (loc.kind === "gap") {
|
|
18788
|
+
const decision = resolveSnapTarget(loc, target);
|
|
18789
|
+
if (decision.kind !== "snap") {
|
|
18790
|
+
if (!this.scrubNoFootageLogged) {
|
|
18791
|
+
this.scrubNoFootageLogged = true;
|
|
18792
|
+
if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
|
|
18793
|
+
epochMs: target,
|
|
18794
|
+
nearestMs: decision.snapMs,
|
|
18795
|
+
distanceMs: decision.distanceMs
|
|
18796
|
+
});
|
|
18797
|
+
else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
18798
|
+
}
|
|
18799
|
+
return;
|
|
18800
|
+
}
|
|
18801
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
18802
|
+
deviceId: this.deps.deviceId,
|
|
18803
|
+
profile: this.deps.profile,
|
|
18804
|
+
epochMs: decision.snapMs
|
|
18805
|
+
}));
|
|
18806
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18807
|
+
if (snapped.kind !== "segment") {
|
|
18808
|
+
if (!this.scrubNoFootageLogged) {
|
|
18809
|
+
this.scrubNoFootageLogged = true;
|
|
18810
|
+
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
18811
|
+
}
|
|
18812
|
+
return;
|
|
18813
|
+
}
|
|
18814
|
+
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
18815
|
+
requestedMs: target,
|
|
18816
|
+
snappedMs: decision.snapMs
|
|
18817
|
+
});
|
|
18818
|
+
loc = snapped;
|
|
18819
|
+
effTarget = decision.snapMs;
|
|
18820
|
+
}
|
|
18370
18821
|
let seg = this.scrubSegment;
|
|
18371
18822
|
if (!seg || seg.startMs !== loc.startMs) {
|
|
18372
18823
|
seg = this.scrubCacheGet(loc.startMs);
|
|
18373
18824
|
if (!seg) {
|
|
18374
|
-
const loaded = await this.loadAus(loc.startMs, false
|
|
18825
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
18375
18826
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
18376
18827
|
seg = {
|
|
18377
18828
|
startMs: loc.startMs,
|
|
@@ -18382,26 +18833,80 @@ var RecordedFeeder = class {
|
|
|
18382
18833
|
}
|
|
18383
18834
|
this.scrubSegment = seg;
|
|
18384
18835
|
}
|
|
18385
|
-
const
|
|
18386
|
-
|
|
18387
|
-
|
|
18388
|
-
|
|
18836
|
+
const prevTarget = this.scrubPrevTarget;
|
|
18837
|
+
const cursor = this.scrubCursor;
|
|
18838
|
+
const targetIdx = this.pickScrubIndex(seg, effTarget, false);
|
|
18839
|
+
const forwardDelta = prevTarget === null ? null : effTarget - prevTarget;
|
|
18840
|
+
const withinWindow = forwardDelta !== null && forwardDelta > 0 && forwardDelta <= SCRUB_SWEEP_WINDOW_MS;
|
|
18841
|
+
const sameFragment = cursor !== null && cursor.fragStartMs === seg.startMs;
|
|
18842
|
+
const adjacentForward = cursor !== null && seg.startMs === cursor.fragStartMs + cursor.fragDurMs;
|
|
18843
|
+
let aus;
|
|
18844
|
+
let firstIdx;
|
|
18845
|
+
let mode;
|
|
18846
|
+
let crossesFragment = false;
|
|
18847
|
+
if (withinWindow && sameFragment && cursor !== null && targetIdx > cursor.idx) {
|
|
18848
|
+
mode = "sweep";
|
|
18849
|
+
firstIdx = cursor.idx + 1;
|
|
18850
|
+
aus = seg.aus.slice(firstIdx, targetIdx + 1);
|
|
18851
|
+
} else if (withinWindow && adjacentForward) {
|
|
18852
|
+
mode = "sweep";
|
|
18853
|
+
firstIdx = 0;
|
|
18854
|
+
aus = seg.aus.slice(0, targetIdx + 1);
|
|
18855
|
+
crossesFragment = true;
|
|
18856
|
+
} else {
|
|
18857
|
+
mode = "jump";
|
|
18858
|
+
firstIdx = this.pickScrubIndex(seg, effTarget, true);
|
|
18859
|
+
const kf = seg.aus[firstIdx];
|
|
18860
|
+
aus = kf === void 0 ? [] : [kf];
|
|
18861
|
+
}
|
|
18862
|
+
const lastAu = aus[aus.length - 1];
|
|
18863
|
+
if (lastAu === void 0) {
|
|
18864
|
+
this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
|
|
18865
|
+
this.scrubPrevTarget = effTarget;
|
|
18866
|
+
return;
|
|
18867
|
+
}
|
|
18868
|
+
if (mode === "jump" && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18869
|
+
else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
|
|
18870
|
+
for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
18871
|
+
this.scrubOutstanding.push(aus.length);
|
|
18872
|
+
this.scrubCredit -= 1;
|
|
18389
18873
|
this.clockHasAdvanced = true;
|
|
18390
|
-
|
|
18391
|
-
this.
|
|
18392
|
-
|
|
18393
|
-
|
|
18874
|
+
if (mode === "jump") this.scrubJumps += 1;
|
|
18875
|
+
else this.scrubSweeps += 1;
|
|
18876
|
+
this.scrubAusPushed += aus.length;
|
|
18877
|
+
this.scrubCursor = {
|
|
18878
|
+
fragStartMs: seg.startMs,
|
|
18879
|
+
fragDurMs: seg.durMs,
|
|
18880
|
+
idx: firstIdx + aus.length - 1
|
|
18881
|
+
};
|
|
18882
|
+
this.deps.onLog?.("scrub-feeder:pushed", {
|
|
18883
|
+
epochMs: effTarget,
|
|
18884
|
+
mode,
|
|
18885
|
+
aus: aus.length,
|
|
18886
|
+
codec: lastAu.codec,
|
|
18887
|
+
bytes: lastAu.data.length,
|
|
18888
|
+
creditLeft: this.scrubCredit
|
|
18889
|
+
});
|
|
18890
|
+
this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
|
|
18891
|
+
this.maybeScrubPrefetch(seg, effTarget, gen);
|
|
18892
|
+
} catch (err) {
|
|
18893
|
+
this.deps.onLog?.("scrub-feeder:tick-failed", {
|
|
18894
|
+
epochMs: target,
|
|
18895
|
+
error: err instanceof Error ? err.message : String(err)
|
|
18896
|
+
});
|
|
18897
|
+
} finally {
|
|
18898
|
+
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);
|
|
18394
18899
|
else this.scrubTimer = null;
|
|
18395
18900
|
}
|
|
18396
18901
|
}
|
|
18397
18902
|
/**
|
|
18398
|
-
* Index of the nearest
|
|
18903
|
+
* Index of the nearest AU at-or-before the drag target within `seg`
|
|
18399
18904
|
* (default 0 — the fragment's params-fused IDR, which the demux contract
|
|
18400
|
-
* guarantees).
|
|
18401
|
-
*
|
|
18402
|
-
*
|
|
18905
|
+
* guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
|
|
18906
|
+
* mode, whose pushed frame must be standalone-decodable; sweep mode picks
|
|
18907
|
+
* ANY frame, which is what makes the flow frame-granular.
|
|
18403
18908
|
*/
|
|
18404
|
-
|
|
18909
|
+
pickScrubIndex(seg, target, keyframesOnly) {
|
|
18405
18910
|
const first = seg.aus[0];
|
|
18406
18911
|
if (!first) return 0;
|
|
18407
18912
|
const offsetMs = target - seg.startMs;
|
|
@@ -18410,7 +18915,7 @@ var RecordedFeeder = class {
|
|
|
18410
18915
|
const au = seg.aus[i];
|
|
18411
18916
|
if (!au) continue;
|
|
18412
18917
|
if (au.ptsMs - first.ptsMs > offsetMs) break;
|
|
18413
|
-
if (au.keyframe) idx = i;
|
|
18918
|
+
if (!keyframesOnly || au.keyframe) idx = i;
|
|
18414
18919
|
}
|
|
18415
18920
|
return idx;
|
|
18416
18921
|
}
|
|
@@ -18481,7 +18986,7 @@ var RecordedFeeder = class {
|
|
|
18481
18986
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18482
18987
|
if (loc.kind !== "segment") return;
|
|
18483
18988
|
if (this.scrubCache.has(loc.startMs)) return;
|
|
18484
|
-
const loaded = await this.loadAus(loc.startMs, false
|
|
18989
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
18485
18990
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
18486
18991
|
this.scrubCachePut({
|
|
18487
18992
|
startMs: loc.startMs,
|
|
@@ -18831,10 +19336,8 @@ var RecordedFeeder = class {
|
|
|
18831
19336
|
* access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
|
|
18832
19337
|
* (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
|
|
18833
19338
|
* never blocks video). `wantAudio: false` skips the audio transcode entirely
|
|
18834
|
-
* (paused scrubs would throw it away).
|
|
18835
|
-
|
|
18836
|
-
* when absent — flag-off byte-identity). */
|
|
18837
|
-
async loadAus(startMs, wantAudio = true, useScrubDemux = false) {
|
|
19339
|
+
* (paused scrubs would throw it away). */
|
|
19340
|
+
async loadAus(startMs, wantAudio = true) {
|
|
18838
19341
|
const token = this.seekToken;
|
|
18839
19342
|
const bytes = await this.withTimeout(this.deps.readBytes({
|
|
18840
19343
|
deviceId: this.deps.deviceId,
|
|
@@ -18842,8 +19345,7 @@ var RecordedFeeder = class {
|
|
|
18842
19345
|
startMs
|
|
18843
19346
|
}));
|
|
18844
19347
|
if (this.disposed || token !== this.seekToken) return null;
|
|
18845
|
-
const
|
|
18846
|
-
const video = await this.withTimeout(demux(bytes));
|
|
19348
|
+
const video = await this.withTimeout(this.deps.demux(bytes));
|
|
18847
19349
|
if (this.disposed || token !== this.seekToken) return null;
|
|
18848
19350
|
let audio = [];
|
|
18849
19351
|
if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
|
|
@@ -18889,6 +19391,11 @@ var RecordedFeeder = class {
|
|
|
18889
19391
|
}
|
|
18890
19392
|
/** Stop everything: invalidate in-flight work and tear down the position timer. */
|
|
18891
19393
|
dispose() {
|
|
19394
|
+
if (this.scrubbing) this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
19395
|
+
jumps: this.scrubJumps,
|
|
19396
|
+
sweeps: this.scrubSweeps,
|
|
19397
|
+
aus: this.scrubAusPushed
|
|
19398
|
+
});
|
|
18892
19399
|
this.disposed = true;
|
|
18893
19400
|
this.seekToken++;
|
|
18894
19401
|
this.advanceToken++;
|
|
@@ -18898,6 +19405,12 @@ var RecordedFeeder = class {
|
|
|
18898
19405
|
this.scrubCache.clear();
|
|
18899
19406
|
this.scrubPrevTarget = null;
|
|
18900
19407
|
this.scrubDirRun = 0;
|
|
19408
|
+
this.scrubCursor = null;
|
|
19409
|
+
this.scrubJumps = 0;
|
|
19410
|
+
this.scrubSweeps = 0;
|
|
19411
|
+
this.scrubAusPushed = 0;
|
|
19412
|
+
this.scrubNoFootageLogged = false;
|
|
19413
|
+
this.scrubOutstanding = [];
|
|
18901
19414
|
if (this.scrubTimer) {
|
|
18902
19415
|
clearTimeout(this.scrubTimer);
|
|
18903
19416
|
this.scrubTimer = null;
|
|
@@ -18925,6 +19438,16 @@ var TimelineSession = class {
|
|
|
18925
19438
|
this.deps = deps;
|
|
18926
19439
|
deps.session.setLiveFeedGate(false);
|
|
18927
19440
|
}
|
|
19441
|
+
/**
|
|
19442
|
+
* Which source the track is currently carrying. Read by the broker's
|
|
19443
|
+
* adaptive ladder, which must NOT act while the pointer is off live: a tier
|
|
19444
|
+
* switch is a close-and-recreate re-offer, and that disposes this
|
|
19445
|
+
* coordinator — the viewer silently lands back on live at the wrong moment
|
|
19446
|
+
* with no code path to restore the pointer.
|
|
19447
|
+
*/
|
|
19448
|
+
get currentMode() {
|
|
19449
|
+
return this.mode;
|
|
19450
|
+
}
|
|
18928
19451
|
async handleControl(msg) {
|
|
18929
19452
|
switch (msg.t) {
|
|
18930
19453
|
case "playRecorded":
|
|
@@ -18941,6 +19464,9 @@ var TimelineSession = class {
|
|
|
18941
19464
|
case "scrubTo":
|
|
18942
19465
|
if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
|
|
18943
19466
|
break;
|
|
19467
|
+
case "scrubAck":
|
|
19468
|
+
if (this.mode === "recorded") this.feeder?.scrubAck();
|
|
19469
|
+
break;
|
|
18944
19470
|
case "setRate":
|
|
18945
19471
|
this.feeder?.setRate(msg.rate);
|
|
18946
19472
|
break;
|
|
@@ -18948,6 +19474,7 @@ var TimelineSession = class {
|
|
|
18948
19474
|
if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
|
|
18949
19475
|
break;
|
|
18950
19476
|
case "setProfile":
|
|
19477
|
+
if (msg.profile === this.profile) break;
|
|
18951
19478
|
this.profile = msg.profile;
|
|
18952
19479
|
if (this.mode === "recorded") this.enterRecorded();
|
|
18953
19480
|
break;
|
|
@@ -25662,25 +26189,6 @@ async function extractSamples(segment, segmentStartMs) {
|
|
|
25662
26189
|
samples: collected
|
|
25663
26190
|
} : null;
|
|
25664
26191
|
}
|
|
25665
|
-
/**
|
|
25666
|
-
* Demux a recorded LOW `.m4s` segment and return its sync samples plus the
|
|
25667
|
-
* WebCodecs decoder config, or null if the segment carries no video / no
|
|
25668
|
-
* decodable keyframe. `segmentStartMs` anchors keyframe timestamps to absolute
|
|
25669
|
-
* epoch ms; the first sample's cts is the zero reference.
|
|
25670
|
-
*/
|
|
25671
|
-
async function extractKeyframes(segment, segmentStartMs) {
|
|
25672
|
-
const extracted = await extractSamples(segment, segmentStartMs);
|
|
25673
|
-
if (!extracted) return null;
|
|
25674
|
-
const keyframes = extracted.samples.filter((s) => s.isSync).map((s) => ({
|
|
25675
|
-
ts: s.ts,
|
|
25676
|
-
data: s.data
|
|
25677
|
-
}));
|
|
25678
|
-
return keyframes.length > 0 ? {
|
|
25679
|
-
codec: extracted.codec,
|
|
25680
|
-
description: extracted.description,
|
|
25681
|
-
keyframes
|
|
25682
|
-
} : null;
|
|
25683
|
-
}
|
|
25684
26192
|
//#endregion
|
|
25685
26193
|
//#region src/stream-broker/recorded/avcc-annexb.ts
|
|
25686
26194
|
/**
|
|
@@ -26184,33 +26692,6 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
|
|
|
26184
26692
|
"pipe:1"
|
|
26185
26693
|
];
|
|
26186
26694
|
/**
|
|
26187
|
-
* ffmpeg arguments for the SCRUB keyframe-only demux: same crash-isolated
|
|
26188
|
-
* `-c:v copy` mpegts remux as {@link SEGMENT_DEMUX_FFMPEG_ARGS}, but `-frames:v 1`
|
|
26189
|
-
* stops after the FIRST output access unit — a recorded fragment is a single
|
|
26190
|
-
* IDR-led GOP, so frame 1 is the params-fused keyframe the scrub sub-mode needs
|
|
26191
|
-
* (it never renders a delta frame). This reliably yields exactly the IDR with
|
|
26192
|
-
* its VPS/SPS/PPS fused, whereas input-side `-discard nokey` is a no-op over the
|
|
26193
|
-
* `pipe:0` fragmented-mp4 demuxer (empirically kept 39/40 packets). Exported for
|
|
26194
|
-
* the spec.
|
|
26195
|
-
*/
|
|
26196
|
-
var SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS = [
|
|
26197
|
-
"-hide_banner",
|
|
26198
|
-
"-loglevel",
|
|
26199
|
-
"error",
|
|
26200
|
-
"-i",
|
|
26201
|
-
"pipe:0",
|
|
26202
|
-
"-map",
|
|
26203
|
-
"0:v:0",
|
|
26204
|
-
"-frames:v",
|
|
26205
|
-
"1",
|
|
26206
|
-
"-c:v",
|
|
26207
|
-
"copy",
|
|
26208
|
-
"-an",
|
|
26209
|
-
"-f",
|
|
26210
|
-
"mpegts",
|
|
26211
|
-
"pipe:1"
|
|
26212
|
-
];
|
|
26213
|
-
/**
|
|
26214
26695
|
* Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
|
|
26215
26696
|
* access units.
|
|
26216
26697
|
*
|
|
@@ -26236,113 +26717,35 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
|
|
|
26236
26717
|
codec
|
|
26237
26718
|
}));
|
|
26238
26719
|
}
|
|
26239
|
-
/**
|
|
26240
|
-
* Keyframe-only demux for the feeder-scrub sub-mode: remux with `-frames:v 1`
|
|
26241
|
-
* (single-GOP fragment ⇒ the one IDR) and return ONLY keyframe access units.
|
|
26242
|
-
* The scrub path never renders delta frames — it always pushes the nearest
|
|
26243
|
-
* keyframe ≤ the drag target — so extracting only the IDR keeps the per-session
|
|
26244
|
-
* scrub fragment cache tiny (one AU/fragment instead of the full ~40) with no
|
|
26245
|
-
* loss of scrub fidelity.
|
|
26246
|
-
*
|
|
26247
|
-
* Wall time per fragment is dominated by the ffmpeg subprocess spawn, so this is
|
|
26248
|
-
* NOT meaningfully faster per fragment than {@link demuxSegmentToAnnexB}; the
|
|
26249
|
-
* frequency win comes from the feeder's fragment cache + direction-aware
|
|
26250
|
-
* prefetch (which eliminate / overlap the demux), for which this compact
|
|
26251
|
-
* keyframe-only payload is the cache unit.
|
|
26252
|
-
*
|
|
26253
|
-
* Defensive parse-side filter: keeps `keyframe` AUs, falling back to the first
|
|
26254
|
-
* AU (the demux contract's params-fused IDR) if none were flagged, so the result
|
|
26255
|
-
* is keyframes-only yet never empty for a valid fragment.
|
|
26256
|
-
*
|
|
26257
|
-
* @param bytes - The full `.m4s` fragment (one moov + one IDR-led GOP).
|
|
26258
|
-
* @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
|
|
26259
|
-
*/
|
|
26260
|
-
async function demuxSegmentKeyframesToAnnexB(bytes, ffmpegPath = "ffmpeg") {
|
|
26261
|
-
const { codec, accessUnits } = demuxMpegTsToAnnexB(await runFfmpegOnce({
|
|
26262
|
-
ffmpegPath,
|
|
26263
|
-
args: SEGMENT_DEMUX_SCRUB_FFMPEG_ARGS,
|
|
26264
|
-
input: bytes
|
|
26265
|
-
}));
|
|
26266
|
-
const mapped = accessUnits.map((au) => ({
|
|
26267
|
-
data: au.data,
|
|
26268
|
-
ptsMs: au.ptsMs,
|
|
26269
|
-
keyframe: au.keyframe,
|
|
26270
|
-
codec
|
|
26271
|
-
}));
|
|
26272
|
-
const keyframes = mapped.filter((au) => au.keyframe);
|
|
26273
|
-
return keyframes.length > 0 ? keyframes : mapped.slice(0, 1);
|
|
26274
|
-
}
|
|
26275
26720
|
//#endregion
|
|
26276
26721
|
//#region src/stream-broker/recorded/segment-demux-js.ts
|
|
26277
26722
|
/**
|
|
26278
26723
|
* segment-demux-js — PURE-JS in-process demux for recorded playback.
|
|
26279
26724
|
*
|
|
26280
|
-
*
|
|
26281
|
-
*
|
|
26282
|
-
*
|
|
26283
|
-
*
|
|
26284
|
-
* (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
|
|
26725
|
+
* {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} — FULL playback
|
|
26726
|
+
* (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
|
|
26727
|
+
* Both playback AND scrub demux through this since sweep mode (the scrub train
|
|
26728
|
+
* pushes predicted frames, so the whole GOP is needed either way).
|
|
26285
26729
|
*
|
|
26286
|
-
* Replaces the per-fragment ffmpeg spawn (
|
|
26287
|
-
*
|
|
26730
|
+
* Replaces the per-fragment ffmpeg spawn (a ~140 ms subprocess floor) with an
|
|
26731
|
+
* in-process mp4box parse. A recorded `.m4s`
|
|
26288
26732
|
* is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
|
|
26289
26733
|
* carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
|
|
26290
26734
|
* sample(s) are all in the bytes we already hold — no init segment to fetch.
|
|
26291
26735
|
*
|
|
26292
|
-
* Flow:
|
|
26293
|
-
*
|
|
26294
|
-
*
|
|
26295
|
-
* {@link buildKeyframeAnnexB}
|
|
26296
|
-
*
|
|
26297
|
-
*
|
|
26298
|
-
* `
|
|
26299
|
-
* sample — the only thing the feeder reads, via `au.ptsMs - first.ptsMs`).
|
|
26300
|
-
*
|
|
26301
|
-
* Anchoring `extractKeyframes(bytes, 0)` makes the first sync sample's `ts` = 0,
|
|
26302
|
-
* so `ptsMs` is a plain segment-relative offset in ms, exactly like the ffmpeg
|
|
26303
|
-
* demux's PES-derived deltas — `clampIntoSegment`/position reporting are
|
|
26304
|
-
* unaffected.
|
|
26736
|
+
* Flow: {@link extractSamples} (the proven mp4box demux the recorder scrub
|
|
26737
|
+
* module already ships — box parsing is NOT duplicated here) yields the codec
|
|
26738
|
+
* `description` + length-prefixed samples; sync samples are fused with their
|
|
26739
|
+
* parameter sets ({@link buildKeyframeAnnexB}), delta samples converted plain
|
|
26740
|
+
* ({@link avccSampleToAnnexB}). `ptsMs` is segment-relative (anchored to 0 at
|
|
26741
|
+
* the first sample), exactly like the ffmpeg demux's PES-derived deltas —
|
|
26742
|
+
* `clampIntoSegment`/position reporting are unaffected.
|
|
26305
26743
|
*/
|
|
26306
26744
|
/** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
|
|
26307
26745
|
function isHevcCodec(codec) {
|
|
26308
26746
|
return /^h[ev][vc]1\./i.test(codec);
|
|
26309
26747
|
}
|
|
26310
26748
|
/**
|
|
26311
|
-
* Pure-JS keyframe-only demux of one self-contained `.m4s` fragment. Returns the
|
|
26312
|
-
* fragment's sync-sample access unit(s) as params-fused Annex-B, or `null` if
|
|
26313
|
-
* mp4box found no video / no decodable keyframe (caller falls back to ffmpeg).
|
|
26314
|
-
* Throws only on a genuinely malformed decoder config / sample — the wrapper
|
|
26315
|
-
* ({@link demuxSegmentKeyframesPreferJs}) turns that into an ffmpeg fallback.
|
|
26316
|
-
*/
|
|
26317
|
-
async function demuxSegmentKeyframesJs(bytes) {
|
|
26318
|
-
const extracted = await extractKeyframes(bytes, 0);
|
|
26319
|
-
if (!extracted || extracted.keyframes.length === 0) return null;
|
|
26320
|
-
const isHevc = isHevcCodec(extracted.codec);
|
|
26321
|
-
const codec = isHevc ? "H265" : "H264";
|
|
26322
|
-
return extracted.keyframes.slice(0, 1).map((k) => ({
|
|
26323
|
-
data: buildKeyframeAnnexB(k.data, extracted.description, isHevc),
|
|
26324
|
-
ptsMs: k.ts,
|
|
26325
|
-
keyframe: true,
|
|
26326
|
-
codec
|
|
26327
|
-
}));
|
|
26328
|
-
}
|
|
26329
|
-
/**
|
|
26330
|
-
* Feeder-scrub demux: try the pure-JS keyframe extractor first, fall back to the
|
|
26331
|
-
* ffmpeg spawn ({@link demuxSegmentKeyframesToAnnexB}) if the JS parse yields no
|
|
26332
|
-
* keyframe or throws on malformed bytes. Preserves the exact {@link AccessUnit}
|
|
26333
|
-
* contract either way, so it is a drop-in for `RecordedFeederDeps.demuxScrub`.
|
|
26334
|
-
*/
|
|
26335
|
-
async function demuxSegmentKeyframesPreferJs(bytes, opts = {}) {
|
|
26336
|
-
try {
|
|
26337
|
-
const js = await demuxSegmentKeyframesJs(bytes);
|
|
26338
|
-
if (js && js.length > 0) return js;
|
|
26339
|
-
opts.onFfmpegFallback?.("mp4box parse found no keyframe");
|
|
26340
|
-
} catch (err) {
|
|
26341
|
-
opts.onFfmpegFallback?.(`mp4box parse threw: ${String(err)}`);
|
|
26342
|
-
}
|
|
26343
|
-
return demuxSegmentKeyframesToAnnexB(bytes, opts.ffmpegPath);
|
|
26344
|
-
}
|
|
26345
|
-
/**
|
|
26346
26749
|
* Pure-JS FULL-playback demux of one self-contained `.m4s` fragment: return
|
|
26347
26750
|
* EVERY access unit (sync + delta) as params-fused Annex-B with segment-relative
|
|
26348
26751
|
* `ptsMs`, matching the {@link AccessUnit} contract of {@link demuxSegmentToAnnexB}
|
|
@@ -26651,7 +27054,8 @@ var ADAPTIVE_CONFIG = {
|
|
|
26651
27054
|
minFailPercent: .05,
|
|
26652
27055
|
waitAfterResetMs: 8e3,
|
|
26653
27056
|
upgradeHealthyMs: 6e4,
|
|
26654
|
-
minTimeBetweenChangesMs: 6e4
|
|
27057
|
+
minTimeBetweenChangesMs: 6e4,
|
|
27058
|
+
maxUpgradeBackoffMs: 9e5
|
|
26655
27059
|
};
|
|
26656
27060
|
/**
|
|
26657
27061
|
* True when `broker` is an in-process StreamBroker exposing the Opus egress
|
|
@@ -26837,6 +27241,24 @@ var BrokerWebrtcServer = class {
|
|
|
26837
27241
|
/** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
|
|
26838
27242
|
brokers = /* @__PURE__ */ new Map();
|
|
26839
27243
|
/**
|
|
27244
|
+
* Post-session warmth: when the LAST WebRTC session on a broker closes, a
|
|
27245
|
+
* ghost subscription keeps the source dialed and its RTP ring warm for the
|
|
27246
|
+
* linger window, so the dominant remote pattern — reopen within minutes —
|
|
27247
|
+
* reconnects with an instant-start GOP replay instead of re-paying the
|
|
27248
|
+
* cold dial + next-IDR wait. `kind:'warmup'` reuses the existing consumer
|
|
27249
|
+
* attribution enum; the label answers "who keeps cam X warm?" in the
|
|
27250
|
+
* operator widget.
|
|
27251
|
+
*/
|
|
27252
|
+
brokerLinger = new BrokerLingerRegistry({ subscribe: (brokerId) => {
|
|
27253
|
+
const broker = this.brokers.get(brokerId);
|
|
27254
|
+
if (!broker) return null;
|
|
27255
|
+
return broker.onEncodedData(() => {}, {
|
|
27256
|
+
kind: "warmup",
|
|
27257
|
+
label: "webrtc-linger",
|
|
27258
|
+
media: "both"
|
|
27259
|
+
});
|
|
27260
|
+
} });
|
|
27261
|
+
/**
|
|
26840
27262
|
* Resolves a brokerId to its current profile tier (`CamProfile`)
|
|
26841
27263
|
* via the manager's `assignments` map, or `null` for brokers that
|
|
26842
27264
|
* are only kept alive by a manual activation. Injected by the
|
|
@@ -26999,16 +27421,20 @@ var BrokerWebrtcServer = class {
|
|
|
26999
27421
|
* RTC controller / Alexa handler closes the session the same way.
|
|
27000
27422
|
*/
|
|
27001
27423
|
async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
|
|
27424
|
+
const offerStart = Date.now();
|
|
27002
27425
|
const setup = await this.setupSessionForBroker(streamId, hints, {
|
|
27003
27426
|
...opts,
|
|
27004
27427
|
clientOfferSdp
|
|
27005
27428
|
});
|
|
27429
|
+
const setupMs = Date.now() - offerStart;
|
|
27006
27430
|
try {
|
|
27007
27431
|
if (opts.relayOnly === true) setup.session.setForceRelayOnly(true);
|
|
27432
|
+
const negotiateStart = Date.now();
|
|
27008
27433
|
const answer = await setup.session.handleOffer({
|
|
27009
27434
|
sdp: clientOfferSdp,
|
|
27010
27435
|
type: "offer"
|
|
27011
27436
|
}, { nonTrickle: opts.nonTrickle === true });
|
|
27437
|
+
const negotiateMs = Date.now() - negotiateStart;
|
|
27012
27438
|
setup.sessionLogger.info("WebRTC session created (client-offer)", { meta: {
|
|
27013
27439
|
sessionId: setup.sessionId,
|
|
27014
27440
|
deviceId: setup.deviceId,
|
|
@@ -27016,7 +27442,11 @@ var BrokerWebrtcServer = class {
|
|
|
27016
27442
|
iceServers: setup.iceServerCount,
|
|
27017
27443
|
codec: setup.sessionCodec,
|
|
27018
27444
|
useRtpRepacketizer: setup.useRtpRepacketizer,
|
|
27019
|
-
relayOnly: opts.relayOnly === true
|
|
27445
|
+
relayOnly: opts.relayOnly === true,
|
|
27446
|
+
setupMs,
|
|
27447
|
+
negotiateMs,
|
|
27448
|
+
totalMs: Date.now() - offerStart,
|
|
27449
|
+
...setup.setupTimings
|
|
27020
27450
|
} });
|
|
27021
27451
|
return {
|
|
27022
27452
|
sessionId: setup.sessionId,
|
|
@@ -27043,6 +27473,11 @@ var BrokerWebrtcServer = class {
|
|
|
27043
27473
|
*/
|
|
27044
27474
|
async setupSessionForBroker(streamId, hints, opts) {
|
|
27045
27475
|
if (this.stopped) throw new Error("Server stopped");
|
|
27476
|
+
const setupStart = Date.now();
|
|
27477
|
+
let ensureTierMs = 0;
|
|
27478
|
+
let warmUpMs = 0;
|
|
27479
|
+
let iceResolveMs = 0;
|
|
27480
|
+
let hwAccelMs = 0;
|
|
27046
27481
|
const isAdaptiveStream = streamId.endsWith("/adaptive");
|
|
27047
27482
|
const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
|
|
27048
27483
|
const resolveHints = intent !== void 0 ? {
|
|
@@ -27052,10 +27487,13 @@ var BrokerWebrtcServer = class {
|
|
|
27052
27487
|
if (isAdaptiveStream) {
|
|
27053
27488
|
const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
|
|
27054
27489
|
const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
|
|
27490
|
+
const ensureTierStart = Date.now();
|
|
27055
27491
|
try {
|
|
27056
27492
|
await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
|
|
27057
27493
|
} catch (err) {
|
|
27058
27494
|
throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${errMsg(err)}`);
|
|
27495
|
+
} finally {
|
|
27496
|
+
ensureTierMs = Date.now() - ensureTierStart;
|
|
27059
27497
|
}
|
|
27060
27498
|
}
|
|
27061
27499
|
const brokerId = this.resolveBrokerId(streamId, resolveHints);
|
|
@@ -27077,6 +27515,7 @@ var BrokerWebrtcServer = class {
|
|
|
27077
27515
|
kind: "warmup",
|
|
27078
27516
|
media: "both"
|
|
27079
27517
|
});
|
|
27518
|
+
const warmUpStart = Date.now();
|
|
27080
27519
|
await new Promise((resolve) => {
|
|
27081
27520
|
const timer = setTimeout(() => {
|
|
27082
27521
|
sessionLogger.warn("broker SDP wait timed out — falling back to default codec", { meta: {
|
|
@@ -27092,9 +27531,11 @@ var BrokerWebrtcServer = class {
|
|
|
27092
27531
|
resolve();
|
|
27093
27532
|
});
|
|
27094
27533
|
});
|
|
27534
|
+
warmUpMs = Date.now() - warmUpStart;
|
|
27095
27535
|
sessionLogger.info("broker warm-up complete", { meta: {
|
|
27096
27536
|
brokerId,
|
|
27097
|
-
detectedCodec: broker.getStats().codec ?? null
|
|
27537
|
+
detectedCodec: broker.getStats().codec ?? null,
|
|
27538
|
+
waitedMs: warmUpMs
|
|
27098
27539
|
} });
|
|
27099
27540
|
}
|
|
27100
27541
|
const brokerCodec = broker.getStats().codec ?? "h264";
|
|
@@ -27278,6 +27719,7 @@ var BrokerWebrtcServer = class {
|
|
|
27278
27719
|
}
|
|
27279
27720
|
}, annexbAttribution);
|
|
27280
27721
|
warmUpUnsubscribe?.();
|
|
27722
|
+
this.brokerLinger.cancel(brokerId);
|
|
27281
27723
|
const preBuffer = broker.getPreBuffer();
|
|
27282
27724
|
const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
|
|
27283
27725
|
for (const pkt of preBuffer) if (pkt.type === "video") {
|
|
@@ -27304,7 +27746,9 @@ var BrokerWebrtcServer = class {
|
|
|
27304
27746
|
}
|
|
27305
27747
|
});
|
|
27306
27748
|
}
|
|
27749
|
+
const iceResolveStart = Date.now();
|
|
27307
27750
|
const allIceServers = await this.resolveIceServers();
|
|
27751
|
+
iceResolveMs = Date.now() - iceResolveStart;
|
|
27308
27752
|
const iceServers = opts.disableTurn === true ? allIceServers.map((entry) => {
|
|
27309
27753
|
const stunOnly = (Array.isArray(entry.urls) ? entry.urls : [entry.urls]).filter((u) => !u.startsWith("turn:") && !u.startsWith("turns:"));
|
|
27310
27754
|
if (stunOnly.length === 0) return null;
|
|
@@ -27329,13 +27773,16 @@ var BrokerWebrtcServer = class {
|
|
|
27329
27773
|
sourceProfileLevelId,
|
|
27330
27774
|
offeredProfileLevelIds
|
|
27331
27775
|
} });
|
|
27776
|
+
const hwAccelStart = Date.now();
|
|
27777
|
+
const transcodeDecodeHwAccel = await this.getTranscodeDecodeHwAccel();
|
|
27778
|
+
hwAccelMs = Date.now() - hwAccelStart;
|
|
27332
27779
|
session = new AdaptiveSession({
|
|
27333
27780
|
sessionId,
|
|
27334
27781
|
source,
|
|
27335
27782
|
sourceCodec: sessionCodec,
|
|
27336
27783
|
transcodeToBaseline,
|
|
27337
27784
|
transcodeProfile: effectiveTranscodeProfile,
|
|
27338
|
-
transcodeDecodeHwAccel
|
|
27785
|
+
transcodeDecodeHwAccel,
|
|
27339
27786
|
resolveTranscodeSourceUrl: () => this.sourceRestreamUrlResolver?.(brokerId) ?? null,
|
|
27340
27787
|
iceConfig: {
|
|
27341
27788
|
iceServers,
|
|
@@ -27447,7 +27894,6 @@ var BrokerWebrtcServer = class {
|
|
|
27447
27894
|
const locateRecorded = this.locateRecordedSegment;
|
|
27448
27895
|
const readRecorded = this.readRecordedSegmentBytes;
|
|
27449
27896
|
const resolveFfmpeg = this.resolveFfmpegPath;
|
|
27450
|
-
let scrubJsFallbackLogged = false;
|
|
27451
27897
|
let fullJsFallbackLogged = false;
|
|
27452
27898
|
let audioOpusFallbackLogged = false;
|
|
27453
27899
|
if (locateRecorded && readRecorded) {
|
|
@@ -27475,19 +27921,6 @@ var BrokerWebrtcServer = class {
|
|
|
27475
27921
|
} });
|
|
27476
27922
|
}
|
|
27477
27923
|
}),
|
|
27478
|
-
demuxScrub: (b) => demuxSegmentKeyframesPreferJs(b, {
|
|
27479
|
-
ffmpegPath: resolveFfmpeg?.(),
|
|
27480
|
-
onFfmpegFallback: (reason) => {
|
|
27481
|
-
if (scrubJsFallbackLogged) return;
|
|
27482
|
-
scrubJsFallbackLogged = true;
|
|
27483
|
-
sessionLogger.warn("recorded scrub: JS keyframe demux failed — ffmpeg fallback (once/session)", { meta: {
|
|
27484
|
-
sessionId,
|
|
27485
|
-
deviceId,
|
|
27486
|
-
profile,
|
|
27487
|
-
reason
|
|
27488
|
-
} });
|
|
27489
|
-
}
|
|
27490
|
-
}),
|
|
27491
27924
|
demuxAudio: opusSession ? async (b) => {
|
|
27492
27925
|
return (await demuxSegmentAudioOpusPreferJs(b, {
|
|
27493
27926
|
ffmpegPath: resolveFfmpeg?.(),
|
|
@@ -27523,10 +27956,17 @@ var BrokerWebrtcServer = class {
|
|
|
27523
27956
|
} });
|
|
27524
27957
|
timeline._emitState(s);
|
|
27525
27958
|
},
|
|
27526
|
-
onPosition: (epochMs) => timeline._emitPosition(epochMs)
|
|
27959
|
+
onPosition: (epochMs) => timeline._emitPosition(epochMs),
|
|
27960
|
+
onLog: (msg, data) => sessionLogger.info(msg, { meta: {
|
|
27961
|
+
sessionId,
|
|
27962
|
+
deviceId,
|
|
27963
|
+
profile,
|
|
27964
|
+
...data
|
|
27965
|
+
} })
|
|
27527
27966
|
});
|
|
27528
27967
|
}
|
|
27529
27968
|
});
|
|
27969
|
+
entry.timeline = timeline;
|
|
27530
27970
|
sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
|
|
27531
27971
|
sessionId,
|
|
27532
27972
|
deviceId
|
|
@@ -27576,7 +28016,7 @@ var BrokerWebrtcServer = class {
|
|
|
27576
28016
|
};
|
|
27577
28017
|
if (streamId.endsWith("/adaptive")) {
|
|
27578
28018
|
const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
|
|
27579
|
-
this.startAdaptive(entry, deviceId, initialTier, sessionId);
|
|
28019
|
+
this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures);
|
|
27580
28020
|
}
|
|
27581
28021
|
return {
|
|
27582
28022
|
sessionId,
|
|
@@ -27586,7 +28026,14 @@ var BrokerWebrtcServer = class {
|
|
|
27586
28026
|
brokerId,
|
|
27587
28027
|
sessionCodec,
|
|
27588
28028
|
useRtpRepacketizer,
|
|
27589
|
-
iceServerCount: iceServers.length
|
|
28029
|
+
iceServerCount: iceServers.length,
|
|
28030
|
+
setupTimings: {
|
|
28031
|
+
setupTotalMs: Date.now() - setupStart,
|
|
28032
|
+
ensureTierMs,
|
|
28033
|
+
warmUpMs,
|
|
28034
|
+
iceResolveMs,
|
|
28035
|
+
hwAccelMs
|
|
28036
|
+
}
|
|
27590
28037
|
};
|
|
27591
28038
|
}
|
|
27592
28039
|
async handleAnswer(sessionId, sdpAnswer) {
|
|
@@ -27636,6 +28083,7 @@ var BrokerWebrtcServer = class {
|
|
|
27636
28083
|
async stop() {
|
|
27637
28084
|
if (this.stopped) return;
|
|
27638
28085
|
this.stopped = true;
|
|
28086
|
+
this.brokerLinger.stop();
|
|
27639
28087
|
const closes = [];
|
|
27640
28088
|
for (const [sid, entry] of this.sessions) {
|
|
27641
28089
|
this.cleanupSession(sid);
|
|
@@ -27670,9 +28118,10 @@ var BrokerWebrtcServer = class {
|
|
|
27670
28118
|
* whichever produces the higher loss ratio across its window. The async
|
|
27671
28119
|
* poll is guarded against overlapping ticks via `tickInFlight`.
|
|
27672
28120
|
*/
|
|
27673
|
-
startAdaptive(entry, deviceId, initialTier, sessionId) {
|
|
28121
|
+
startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures) {
|
|
27674
28122
|
const controller = new AdaptiveController({
|
|
27675
28123
|
initialTier,
|
|
28124
|
+
priorFailures,
|
|
27676
28125
|
config: ADAPTIVE_CONFIG,
|
|
27677
28126
|
onSwitchTier: (tier) => {
|
|
27678
28127
|
this.switchSessionTier(entry, deviceId, tier, sessionId);
|
|
@@ -27692,6 +28141,10 @@ var BrokerWebrtcServer = class {
|
|
|
27692
28141
|
tickInFlight = true;
|
|
27693
28142
|
(async () => {
|
|
27694
28143
|
try {
|
|
28144
|
+
if (entry.timeline?.currentMode === "recorded") {
|
|
28145
|
+
controller.suspend(Date.now());
|
|
28146
|
+
return;
|
|
28147
|
+
}
|
|
27695
28148
|
const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
|
|
27696
28149
|
const now = Date.now();
|
|
27697
28150
|
if (pct !== null && pct !== void 0 && pct > 0) {
|
|
@@ -27723,9 +28176,11 @@ var BrokerWebrtcServer = class {
|
|
|
27723
28176
|
*/
|
|
27724
28177
|
switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
|
|
27725
28178
|
const prev = this.adaptiveIntent.get(sessionId);
|
|
28179
|
+
const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
|
|
27726
28180
|
this.adaptiveIntent.set(sessionId, {
|
|
27727
28181
|
prefersTier: tier,
|
|
27728
|
-
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false)
|
|
28182
|
+
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
|
|
28183
|
+
failures
|
|
27729
28184
|
});
|
|
27730
28185
|
const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
|
|
27731
28186
|
entry.session.pendingRenegotiation = {
|
|
@@ -27739,7 +28194,8 @@ var BrokerWebrtcServer = class {
|
|
|
27739
28194
|
deviceId,
|
|
27740
28195
|
tier,
|
|
27741
28196
|
forceTranscodeDown,
|
|
27742
|
-
epoch
|
|
28197
|
+
epoch,
|
|
28198
|
+
failures: Object.fromEntries(failures)
|
|
27743
28199
|
}
|
|
27744
28200
|
});
|
|
27745
28201
|
}
|
|
@@ -27747,6 +28203,12 @@ var BrokerWebrtcServer = class {
|
|
|
27747
28203
|
const entry = this.sessions.get(sessionId);
|
|
27748
28204
|
if (!entry) return;
|
|
27749
28205
|
this.sessions.delete(sessionId);
|
|
28206
|
+
let brokerStillServed = false;
|
|
28207
|
+
for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
|
|
28208
|
+
brokerStillServed = true;
|
|
28209
|
+
break;
|
|
28210
|
+
}
|
|
28211
|
+
if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
|
|
27750
28212
|
this.adaptiveIntent.delete(sessionId);
|
|
27751
28213
|
if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
|
|
27752
28214
|
if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
|
|
@@ -28330,6 +28792,42 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
|
|
|
28330
28792
|
}
|
|
28331
28793
|
};
|
|
28332
28794
|
//#endregion
|
|
28795
|
+
//#region src/stream-broker/event-loop-stall-monitor.ts
|
|
28796
|
+
var DEFAULT_INTERVAL_MS = 100;
|
|
28797
|
+
var DEFAULT_THRESHOLD_MS = 250;
|
|
28798
|
+
var EventLoopStallMonitor = class {
|
|
28799
|
+
intervalMs;
|
|
28800
|
+
thresholdMs;
|
|
28801
|
+
now;
|
|
28802
|
+
onStall;
|
|
28803
|
+
timer = null;
|
|
28804
|
+
lastTickAt = 0;
|
|
28805
|
+
constructor(options) {
|
|
28806
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
28807
|
+
this.thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS;
|
|
28808
|
+
this.now = options.now ?? (() => Date.now());
|
|
28809
|
+
this.onStall = options.onStall;
|
|
28810
|
+
}
|
|
28811
|
+
start() {
|
|
28812
|
+
if (this.timer) return;
|
|
28813
|
+
this.lastTickAt = this.now();
|
|
28814
|
+
this.timer = setInterval(() => {
|
|
28815
|
+
const at = this.now();
|
|
28816
|
+
const lateMs = at - this.lastTickAt - this.intervalMs;
|
|
28817
|
+
this.lastTickAt = at;
|
|
28818
|
+
if (lateMs >= this.thresholdMs) try {
|
|
28819
|
+
this.onStall(lateMs);
|
|
28820
|
+
} catch {}
|
|
28821
|
+
}, this.intervalMs);
|
|
28822
|
+
this.timer.unref?.();
|
|
28823
|
+
}
|
|
28824
|
+
stop() {
|
|
28825
|
+
if (!this.timer) return;
|
|
28826
|
+
clearInterval(this.timer);
|
|
28827
|
+
this.timer = null;
|
|
28828
|
+
}
|
|
28829
|
+
};
|
|
28830
|
+
//#endregion
|
|
28333
28831
|
//#region src/stream-broker/addon.ts
|
|
28334
28832
|
/**
|
|
28335
28833
|
* Hydrate the persisted RTSP restream state (`rtspTokens` / `rtspEnabled`)
|
|
@@ -28422,6 +28920,8 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
28422
28920
|
webrtcServer = null;
|
|
28423
28921
|
/** Handle for the embed SPA data-plane listener (disposed on shutdown). */
|
|
28424
28922
|
embedDataPlane = null;
|
|
28923
|
+
/** Names this process's event-loop stalls — see onInitialize. */
|
|
28924
|
+
loopStallMonitor = null;
|
|
28425
28925
|
/**
|
|
28426
28926
|
* Snapshot-equality cache for the broker-metrics emit. Each
|
|
28427
28927
|
* broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
|
|
@@ -28522,6 +29022,10 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
28522
29022
|
}
|
|
28523
29023
|
}
|
|
28524
29024
|
async onInitialize() {
|
|
29025
|
+
this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
|
|
29026
|
+
this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
|
|
29027
|
+
} });
|
|
29028
|
+
this.loopStallMonitor.start();
|
|
28525
29029
|
this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
|
|
28526
29030
|
this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
|
|
28527
29031
|
this.brokerManager.setEventBus(this.ctx.eventBus);
|
|
@@ -28864,6 +29368,8 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
28864
29368
|
];
|
|
28865
29369
|
}
|
|
28866
29370
|
async onShutdown() {
|
|
29371
|
+
this.loopStallMonitor?.stop();
|
|
29372
|
+
this.loopStallMonitor = null;
|
|
28867
29373
|
this.flushProbeSnapshots(Date.now(), true);
|
|
28868
29374
|
if (this.metricsSnapshotTimer) {
|
|
28869
29375
|
clearInterval(this.metricsSnapshotTimer);
|