@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
|
@@ -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-C6p8jcji.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: {
|
|
@@ -6989,14 +7053,16 @@ var RENDER_TIMEOUT_MS = 2e4;
|
|
|
6989
7053
|
function clipFfmpegArgs(input) {
|
|
6990
7054
|
const scale = `scale=${input.maxWidth}:-2:flags=lanczos`;
|
|
6991
7055
|
const demuxer = input.hevc === true ? "hevc" : "h264";
|
|
7056
|
+
const speed = input.speed ?? 2;
|
|
7057
|
+
const setpts = speed !== 1 ? `,setpts=PTS/${speed}` : "";
|
|
6992
7058
|
if (input.format === "gif") return [
|
|
6993
7059
|
"-y",
|
|
6994
7060
|
"-f",
|
|
6995
7061
|
demuxer,
|
|
6996
7062
|
"-i",
|
|
6997
7063
|
"pipe:0",
|
|
6998
|
-
"-
|
|
6999
|
-
`fps=${input.fps},${scale}`,
|
|
7064
|
+
"-filter_complex",
|
|
7065
|
+
`[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`,
|
|
7000
7066
|
"-loop",
|
|
7001
7067
|
"0",
|
|
7002
7068
|
"-f",
|
|
@@ -7010,7 +7076,7 @@ function clipFfmpegArgs(input) {
|
|
|
7010
7076
|
"-i",
|
|
7011
7077
|
"pipe:0",
|
|
7012
7078
|
"-vf",
|
|
7013
|
-
scale
|
|
7079
|
+
`${scale}${setpts}`,
|
|
7014
7080
|
"-c:v",
|
|
7015
7081
|
"libx264",
|
|
7016
7082
|
"-preset",
|
|
@@ -9730,6 +9796,21 @@ var DecoderSessionProxy = class {
|
|
|
9730
9796
|
this.api = api;
|
|
9731
9797
|
this.sessionId = sessionId;
|
|
9732
9798
|
}
|
|
9799
|
+
/**
|
|
9800
|
+
* Guarantee an empty drain cannot re-poll faster than `POLL_WAIT_MS`,
|
|
9801
|
+
* regardless of the peer. A WELL-BEHAVED decoder blocks server-side for
|
|
9802
|
+
* `waitMs` (await-notify long-poll), so `elapsedMs >= POLL_WAIT_MS` and this
|
|
9803
|
+
* is a no-op. But the loop's liveness must NOT depend on peer courtesy: a
|
|
9804
|
+
* peer that resolves `[]` instantly — the dormant `decoder-nodeav.pullHandles`
|
|
9805
|
+
* stub, an old decoder that predates `waitMs`, a test fake — would otherwise
|
|
9806
|
+
* turn `startPolling`/`startHandlePolling` into a ZERO-YIELD microtask spin
|
|
9807
|
+
* that pins the CPU, starves every timer in the process, and allocates until
|
|
9808
|
+
* the heap dies (observed: a 4 GB OOM in under 15 s).
|
|
9809
|
+
*/
|
|
9810
|
+
async paceEmptyDrain(drainStartedAt) {
|
|
9811
|
+
const remainderMs = POLL_WAIT_MS - (Date.now() - drainStartedAt);
|
|
9812
|
+
if (remainderMs > 0) await new Promise((resolve) => setTimeout(resolve, remainderMs));
|
|
9813
|
+
}
|
|
9733
9814
|
async pushPacket(packet) {
|
|
9734
9815
|
await this.api.pushPacket({
|
|
9735
9816
|
sessionId: this.sessionId,
|
|
@@ -9745,12 +9826,14 @@ var DecoderSessionProxy = class {
|
|
|
9745
9826
|
async startPolling(onFrame) {
|
|
9746
9827
|
this.polling = true;
|
|
9747
9828
|
while (this.polling) {
|
|
9829
|
+
const drainStartedAt = Date.now();
|
|
9748
9830
|
const frames = await this.api.pullFrames({
|
|
9749
9831
|
sessionId: this.sessionId,
|
|
9750
9832
|
maxCount: 4,
|
|
9751
9833
|
waitMs: POLL_WAIT_MS
|
|
9752
9834
|
});
|
|
9753
9835
|
for (const frame of frames) onFrame(frame);
|
|
9836
|
+
if (frames.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
|
|
9754
9837
|
}
|
|
9755
9838
|
}
|
|
9756
9839
|
/**
|
|
@@ -9768,12 +9851,14 @@ var DecoderSessionProxy = class {
|
|
|
9768
9851
|
async startHandlePolling(onHandle) {
|
|
9769
9852
|
this.polling = true;
|
|
9770
9853
|
while (this.polling) try {
|
|
9854
|
+
const drainStartedAt = Date.now();
|
|
9771
9855
|
const handles = await this.api.pullHandles({
|
|
9772
9856
|
sessionId: this.sessionId,
|
|
9773
9857
|
maxCount: 4,
|
|
9774
9858
|
waitMs: POLL_WAIT_MS
|
|
9775
9859
|
});
|
|
9776
9860
|
for (const handle of handles) onHandle(handle);
|
|
9861
|
+
if (handles.length === 0 && this.polling) await this.paceEmptyDrain(drainStartedAt);
|
|
9777
9862
|
} catch (err) {
|
|
9778
9863
|
this.polling = false;
|
|
9779
9864
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -15721,6 +15806,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
15721
15806
|
format: input.format,
|
|
15722
15807
|
maxWidth: input.maxWidth,
|
|
15723
15808
|
fps: input.fps,
|
|
15809
|
+
...input.speed !== void 0 ? { speed: input.speed } : {},
|
|
15724
15810
|
spawn: (cmd, args) => spawn(cmd, [...args], { stdio: [
|
|
15725
15811
|
"pipe",
|
|
15726
15812
|
"pipe",
|
|
@@ -17779,6 +17865,71 @@ function withSourceResolution(consumerDefault, probedSource) {
|
|
|
17779
17865
|
};
|
|
17780
17866
|
}
|
|
17781
17867
|
//#endregion
|
|
17868
|
+
//#region src/stream-broker/webrtc/broker-linger-registry.ts
|
|
17869
|
+
/** Matches the observed reopen gaps (1–2 min) with headroom; well under the
|
|
17870
|
+
* "operator walked away" scale where warmth would be waste. */
|
|
17871
|
+
var DEFAULT_LINGER_MS = 3 * 6e4;
|
|
17872
|
+
var BrokerLingerRegistry = class {
|
|
17873
|
+
lingerMs;
|
|
17874
|
+
subscribe;
|
|
17875
|
+
holds = /* @__PURE__ */ new Map();
|
|
17876
|
+
constructor(options) {
|
|
17877
|
+
this.lingerMs = options.lingerMs ?? DEFAULT_LINGER_MS;
|
|
17878
|
+
this.subscribe = options.subscribe;
|
|
17879
|
+
}
|
|
17880
|
+
/**
|
|
17881
|
+
* Start (or restart) the linger window for a broker that just lost its
|
|
17882
|
+
* last session. Re-holding an already-held broker re-arms the timer
|
|
17883
|
+
* without stacking a second ghost subscription.
|
|
17884
|
+
*/
|
|
17885
|
+
hold(brokerId) {
|
|
17886
|
+
const existing = this.holds.get(brokerId);
|
|
17887
|
+
if (existing) {
|
|
17888
|
+
clearTimeout(existing.timer);
|
|
17889
|
+
existing.timer = this.armTimer(brokerId);
|
|
17890
|
+
return;
|
|
17891
|
+
}
|
|
17892
|
+
const unsubscribe = this.subscribe(brokerId);
|
|
17893
|
+
if (unsubscribe === null) return;
|
|
17894
|
+
this.holds.set(brokerId, {
|
|
17895
|
+
unsubscribe,
|
|
17896
|
+
timer: this.armTimer(brokerId)
|
|
17897
|
+
});
|
|
17898
|
+
}
|
|
17899
|
+
/** Release the hold NOW — a new session attached, the real subscription
|
|
17900
|
+
* carries the demand from here. No-op when nothing is held. */
|
|
17901
|
+
cancel(brokerId) {
|
|
17902
|
+
const hold = this.holds.get(brokerId);
|
|
17903
|
+
if (!hold) return;
|
|
17904
|
+
clearTimeout(hold.timer);
|
|
17905
|
+
this.holds.delete(brokerId);
|
|
17906
|
+
this.release(hold);
|
|
17907
|
+
}
|
|
17908
|
+
/** Release every hold (server shutdown). */
|
|
17909
|
+
stop() {
|
|
17910
|
+
for (const [brokerId, hold] of this.holds) {
|
|
17911
|
+
clearTimeout(hold.timer);
|
|
17912
|
+
this.holds.delete(brokerId);
|
|
17913
|
+
this.release(hold);
|
|
17914
|
+
}
|
|
17915
|
+
}
|
|
17916
|
+
armTimer(brokerId) {
|
|
17917
|
+
const timer = setTimeout(() => {
|
|
17918
|
+
const hold = this.holds.get(brokerId);
|
|
17919
|
+
if (!hold) return;
|
|
17920
|
+
this.holds.delete(brokerId);
|
|
17921
|
+
this.release(hold);
|
|
17922
|
+
}, this.lingerMs);
|
|
17923
|
+
timer.unref?.();
|
|
17924
|
+
return timer;
|
|
17925
|
+
}
|
|
17926
|
+
release(hold) {
|
|
17927
|
+
try {
|
|
17928
|
+
hold.unsubscribe();
|
|
17929
|
+
} catch {}
|
|
17930
|
+
}
|
|
17931
|
+
};
|
|
17932
|
+
//#endregion
|
|
17782
17933
|
//#region src/stream-broker/webrtc/loss-tracker.ts
|
|
17783
17934
|
/** Delta between two RTCP extended (32-bit) highest-sequence values, with
|
|
17784
17935
|
* 32-bit wraparound handling. */
|
|
@@ -17827,6 +17978,24 @@ var LossTracker = class {
|
|
|
17827
17978
|
if (now < this.resetUntil) return false;
|
|
17828
17979
|
return this.lossRatio() > this.config.minFailPercent;
|
|
17829
17980
|
}
|
|
17981
|
+
/**
|
|
17982
|
+
* True when the window holds enough recent receiver feedback for a
|
|
17983
|
+
* "healthy" verdict to MEAN anything.
|
|
17984
|
+
*
|
|
17985
|
+
* `lossRatio()` returns 0 with fewer than two in-window samples, so
|
|
17986
|
+
* `isFailing` is false for a session that has produced NO feedback at all —
|
|
17987
|
+
* a link so bad that RTCP never arrives reads identically to a perfect one.
|
|
17988
|
+
* A downgrade may act on that (0 loss ⇒ don't downgrade, which is safe); an
|
|
17989
|
+
* UPGRADE must not, because it spends bandwidth the session has never been
|
|
17990
|
+
* shown to have. Silence is not health.
|
|
17991
|
+
*/
|
|
17992
|
+
hasEvidence(now) {
|
|
17993
|
+
if (now < this.resetUntil) return false;
|
|
17994
|
+
const cutoff = now - this.config.analyzeWindowMs;
|
|
17995
|
+
let inWindow = 0;
|
|
17996
|
+
for (const s of this.samples) if (s.time > cutoff) inWindow += 1;
|
|
17997
|
+
return inWindow >= 2;
|
|
17998
|
+
}
|
|
17830
17999
|
};
|
|
17831
18000
|
//#endregion
|
|
17832
18001
|
//#region src/stream-broker/webrtc/adaptive-controller.ts
|
|
@@ -17835,8 +18004,17 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
|
|
|
17835
18004
|
* Ladder state machine for an adaptive WebRTC session. Fed loss samples
|
|
17836
18005
|
* (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
|
|
17837
18006
|
* Downgrade order: tier high→mid→low, then MTU reduction at low, then
|
|
17838
|
-
* transcode-down.
|
|
17839
|
-
*
|
|
18007
|
+
* transcode-down. Side effects run through injected callbacks (testable
|
|
18008
|
+
* without werift).
|
|
18009
|
+
*
|
|
18010
|
+
* An UPGRADE requires all three of:
|
|
18011
|
+
* 1. the session clock — measured from the FIRST tick, not from the unix
|
|
18012
|
+
* epoch — showing `upgradeHealthyMs` without a failing verdict;
|
|
18013
|
+
* 2. actual receiver feedback in the window (`LossTracker.hasEvidence`) —
|
|
18014
|
+
* a session that has produced no RTCP at all is not "healthy", it is
|
|
18015
|
+
* silent, and climbing on silence spends bandwidth never shown to exist;
|
|
18016
|
+
* 3. the target tier's re-attempt backoff, which doubles per measured
|
|
18017
|
+
* failure of that tier in this session.
|
|
17840
18018
|
*/
|
|
17841
18019
|
var AdaptiveController = class {
|
|
17842
18020
|
opts;
|
|
@@ -17846,27 +18024,86 @@ var AdaptiveController = class {
|
|
|
17846
18024
|
transcodeEngaged = false;
|
|
17847
18025
|
lastChangeAt = 0;
|
|
17848
18026
|
healthySince = 0;
|
|
18027
|
+
/**
|
|
18028
|
+
* Whether the session clock has been armed by the first evaluation. The
|
|
18029
|
+
* clock is `Date.now()`, so leaving `healthySince` / `lastChangeAt` at 0
|
|
18030
|
+
* would read as "healthy since 1970" and hand out a free upgrade on the
|
|
18031
|
+
* very first tick, before a single loss sample had been seen. A boolean
|
|
18032
|
+
* (not a `startedAt === 0` sentinel) because 0 is a legitimate clock value.
|
|
18033
|
+
*/
|
|
18034
|
+
armed = false;
|
|
18035
|
+
failures;
|
|
17849
18036
|
constructor(opts) {
|
|
17850
18037
|
this.opts = opts;
|
|
17851
18038
|
this.tracker = new LossTracker(opts.config);
|
|
17852
18039
|
this.tier = opts.initialTier;
|
|
18040
|
+
this.failures = new Map(opts.priorFailures ?? []);
|
|
17853
18041
|
}
|
|
17854
18042
|
get currentTier() {
|
|
17855
18043
|
return this.tier;
|
|
17856
18044
|
}
|
|
18045
|
+
/**
|
|
18046
|
+
* Per-tier measured-failure counts, INCLUDING those inherited from earlier
|
|
18047
|
+
* rungs of the same viewer session. The server snapshots this into the
|
|
18048
|
+
* adaptive intent so the session's next controller inherits it.
|
|
18049
|
+
*/
|
|
18050
|
+
get failureCounts() {
|
|
18051
|
+
return this.failures;
|
|
18052
|
+
}
|
|
17857
18053
|
onLoss(sample, now) {
|
|
17858
18054
|
this.tracker.track(sample, now);
|
|
17859
18055
|
}
|
|
18056
|
+
/**
|
|
18057
|
+
* Freeze the ladder for this evaluation instead of ticking it.
|
|
18058
|
+
*
|
|
18059
|
+
* Called while the session's track is NOT carrying the live source — D42
|
|
18060
|
+
* put recorded playback and scrub on the same track with a moving source
|
|
18061
|
+
* pointer, and in those modes the live feed is gated, so receiver feedback
|
|
18062
|
+
* measures the recorded feeder, not the camera tier. Acting on it is
|
|
18063
|
+
* incoherent in both directions: a downgrade cannot shrink a file, an
|
|
18064
|
+
* upgrade cannot enlarge one, and EITHER destroys the feeder (a tier switch
|
|
18065
|
+
* is a close-and-recreate re-offer) — dropping the viewer back to live at
|
|
18066
|
+
* the wrong moment with nothing to restore the pointer.
|
|
18067
|
+
*
|
|
18068
|
+
* Holds the climb clock so time spent in recorded modes can never
|
|
18069
|
+
* accumulate into an upgrade on the return to live.
|
|
18070
|
+
*/
|
|
18071
|
+
suspend(now) {
|
|
18072
|
+
if (!this.armed) {
|
|
18073
|
+
this.armed = true;
|
|
18074
|
+
this.lastChangeAt = now;
|
|
18075
|
+
}
|
|
18076
|
+
this.healthySince = now;
|
|
18077
|
+
}
|
|
17860
18078
|
tick(now) {
|
|
18079
|
+
if (!this.armed) {
|
|
18080
|
+
this.armed = true;
|
|
18081
|
+
this.healthySince = now;
|
|
18082
|
+
this.lastChangeAt = now;
|
|
18083
|
+
}
|
|
17861
18084
|
if (this.tracker.isFailing(now)) {
|
|
17862
18085
|
this.healthySince = now;
|
|
17863
18086
|
this.handleFailing(now);
|
|
17864
18087
|
return;
|
|
17865
18088
|
}
|
|
18089
|
+
if (!this.tracker.hasEvidence(now)) {
|
|
18090
|
+
this.healthySince = now;
|
|
18091
|
+
return;
|
|
18092
|
+
}
|
|
17866
18093
|
this.handleHealthy(now);
|
|
17867
18094
|
}
|
|
17868
18095
|
canUpgrade(now) {
|
|
17869
|
-
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs
|
|
18096
|
+
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
|
|
18097
|
+
}
|
|
18098
|
+
/**
|
|
18099
|
+
* How long the link must read healthy before `tier` may be re-attempted.
|
|
18100
|
+
* Doubles per measured failure of that tier in this session, capped at
|
|
18101
|
+
* `maxUpgradeBackoffMs`.
|
|
18102
|
+
*/
|
|
18103
|
+
requiredHealthyMs(tier) {
|
|
18104
|
+
const failures = this.failures.get(tier) ?? 0;
|
|
18105
|
+
if (failures <= 0) return this.opts.config.upgradeHealthyMs;
|
|
18106
|
+
return Math.min(this.opts.config.upgradeHealthyMs * 2 ** failures, this.opts.config.maxUpgradeBackoffMs);
|
|
17870
18107
|
}
|
|
17871
18108
|
commitChange(now) {
|
|
17872
18109
|
this.lastChangeAt = now;
|
|
@@ -17875,6 +18112,8 @@ var AdaptiveController = class {
|
|
|
17875
18112
|
handleFailing(now) {
|
|
17876
18113
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
17877
18114
|
if (idx < TIER_ORDER.length - 1) {
|
|
18115
|
+
const failed = this.tier;
|
|
18116
|
+
this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
|
|
17878
18117
|
this.tier = TIER_ORDER[idx + 1];
|
|
17879
18118
|
this.opts.onSwitchTier(this.tier);
|
|
17880
18119
|
this.commitChange(now);
|
|
@@ -17893,17 +18132,17 @@ var AdaptiveController = class {
|
|
|
17893
18132
|
}
|
|
17894
18133
|
}
|
|
17895
18134
|
handleHealthy(now) {
|
|
17896
|
-
if (now - this.healthySince < this.opts.config.upgradeHealthyMs) return;
|
|
17897
|
-
if (!this.canUpgrade(now)) return;
|
|
17898
18135
|
const idx = TIER_ORDER.indexOf(this.tier);
|
|
17899
|
-
if (idx
|
|
17900
|
-
|
|
17901
|
-
|
|
17902
|
-
|
|
17903
|
-
|
|
17904
|
-
|
|
17905
|
-
|
|
17906
|
-
|
|
18136
|
+
if (idx <= 0) return;
|
|
18137
|
+
const next = TIER_ORDER[idx - 1];
|
|
18138
|
+
if (now - this.healthySince < this.requiredHealthyMs(next)) return;
|
|
18139
|
+
if (!this.canUpgrade(now)) return;
|
|
18140
|
+
this.tier = next;
|
|
18141
|
+
this.mtuReduced = false;
|
|
18142
|
+
this.transcodeEngaged = false;
|
|
18143
|
+
this.opts.onSwitchTier(next);
|
|
18144
|
+
this.commitChange(now);
|
|
18145
|
+
this.healthySince = now;
|
|
17907
18146
|
}
|
|
17908
18147
|
};
|
|
17909
18148
|
//#endregion
|
|
@@ -17928,6 +18167,7 @@ var ControlMessageSchema = discriminatedUnion("t", [
|
|
|
17928
18167
|
t: literal("scrubTo"),
|
|
17929
18168
|
epoch: number()
|
|
17930
18169
|
}),
|
|
18170
|
+
object({ t: literal("scrubAck") }),
|
|
17931
18171
|
object({
|
|
17932
18172
|
t: literal("setRate"),
|
|
17933
18173
|
rate: number().min(0).max(4)
|
|
@@ -18101,6 +18341,74 @@ var SCRUB_CACHE_MAX = 8;
|
|
|
18101
18341
|
* sustained direction, so a jittery/reversing drag never wastes the slot.
|
|
18102
18342
|
*/
|
|
18103
18343
|
var SCRUB_PREFETCH_DIR_THRESHOLD = 2;
|
|
18344
|
+
/**
|
|
18345
|
+
* Max forward TARGET delta (ms of timeline, between consecutive drag targets)
|
|
18346
|
+
* that SWEEPS predicted frames instead of jumping. A steady forward drag is
|
|
18347
|
+
* playback with a moving target: once a keyframe decoded, the predicted frames
|
|
18348
|
+
* after it decode in sequence at a fraction of an IDR's bytes — that is the
|
|
18349
|
+
* continuous flow. Beyond this window (a fling), or backwards, a jump to an
|
|
18350
|
+
* independently-decodable keyframe is cheaper than the burst; and because a
|
|
18351
|
+
* thin pipe slows the ack-gated ticks down, per-tick deltas GROW under
|
|
18352
|
+
* congestion, so degradation to jump-only is emergent, and visible per push
|
|
18353
|
+
* (`mode` on every scrub-feeder:pushed line).
|
|
18354
|
+
*/
|
|
18355
|
+
var SCRUB_SWEEP_WINDOW_MS = 4e3;
|
|
18356
|
+
/**
|
|
18357
|
+
* Receiver-credit cap for scrub pushes: at most this many keyframes may be
|
|
18358
|
+
* OUTSTANDING (pushed onto the track but not yet acknowledged as RENDERED by
|
|
18359
|
+
* the viewer via `scrubAck`). The coalescing window above shapes the INPUT; on
|
|
18360
|
+
* a thin pipe it is not backpressure — pushing a ~45 KB keyframe every 60 ms
|
|
18361
|
+
* floods a link that delivers one every 500 ms, RTP drops, and nothing paints.
|
|
18362
|
+
* Backpressure is the receiver's: a push spends a credit, a rendered frame
|
|
18363
|
+
* restores one, and with zero credit the newest target PARKS on
|
|
18364
|
+
* `scrubPendingEpochMs` until an ack arrives — a timer never releases work.
|
|
18365
|
+
*/
|
|
18366
|
+
var SCRUB_CREDIT_CAP = 2;
|
|
18367
|
+
/**
|
|
18368
|
+
* How far a `gap` locate may be SNAPPED onto real footage.
|
|
18369
|
+
*
|
|
18370
|
+
* A snap is a CORRECTION — the playhead landed a moment outside coverage and
|
|
18371
|
+
* the operator plainly means the footage beside it — not a SEARCH for wherever
|
|
18372
|
+
* footage happens to exist. The distinction is not cosmetic, because the index
|
|
18373
|
+
* that answers `locate` cannot tell "there is no footage here" apart from "I
|
|
18374
|
+
* have not walked this far back yet": it is rebuilt by walking the archive on
|
|
18375
|
+
* every recorder start, and that walk takes minutes.
|
|
18376
|
+
*
|
|
18377
|
+
* Production 2026-08-02: the recorder had restarted 9 minutes earlier, so
|
|
18378
|
+
* `locate` answered `gap` for the whole timeline with a `nearestEdgeMs` 6
|
|
18379
|
+
* minutes to 23 HOURS away. Unbounded snapping turned every seek and every drag
|
|
18380
|
+
* tick into a segment read against the addon that was busy re-walking, each one
|
|
18381
|
+
* blew the 8 s budget, and both surfaces died. Bounded, all of those answer
|
|
18382
|
+
* `gap` for free — exactly as they did before snapping existed.
|
|
18383
|
+
*
|
|
18384
|
+
* 120 s covers the real cases by a wide margin: an inter-segment crack is
|
|
18385
|
+
* 11–17 ms, and the dead band between two recording bands measured 48 s. Beyond
|
|
18386
|
+
* that the server is answering a question the viewer did not ask; the viewer
|
|
18387
|
+
* holds the coverage ranges and can offer "jump to nearest footage" as an
|
|
18388
|
+
* action instead.
|
|
18389
|
+
*/
|
|
18390
|
+
var SNAP_MAX_DISTANCE_MS = 12e4;
|
|
18391
|
+
/**
|
|
18392
|
+
* Decide where a `gap` locate should snap: forward to the next footage edge,
|
|
18393
|
+
* else backward onto the last ms of the footage behind (`prevEndMs − 1`, which
|
|
18394
|
+
* also hops the 11–17 ms inter-segment cracks). Rejects a candidate further than
|
|
18395
|
+
* {@link SNAP_MAX_DISTANCE_MS} so a partially-walked index cannot teleport the
|
|
18396
|
+
* cursor — and so rejecting costs NO segment I/O at all.
|
|
18397
|
+
*/
|
|
18398
|
+
function resolveSnapTarget(loc, requestedMs) {
|
|
18399
|
+
const snapMs = loc.nearestEdgeMs ?? (typeof loc.prevEndMs === "number" ? loc.prevEndMs - 1 : null);
|
|
18400
|
+
if (snapMs === null) return { kind: "none" };
|
|
18401
|
+
const distanceMs = Math.abs(snapMs - requestedMs);
|
|
18402
|
+
if (distanceMs > SNAP_MAX_DISTANCE_MS) return {
|
|
18403
|
+
kind: "too-far",
|
|
18404
|
+
snapMs,
|
|
18405
|
+
distanceMs
|
|
18406
|
+
};
|
|
18407
|
+
return {
|
|
18408
|
+
kind: "snap",
|
|
18409
|
+
snapMs
|
|
18410
|
+
};
|
|
18411
|
+
}
|
|
18104
18412
|
var RecordedFeeder = class {
|
|
18105
18413
|
deps;
|
|
18106
18414
|
clock = new MonotonicClock();
|
|
@@ -18143,6 +18451,16 @@ var RecordedFeeder = class {
|
|
|
18143
18451
|
*/
|
|
18144
18452
|
scrubbing = false;
|
|
18145
18453
|
scrubToken = 0;
|
|
18454
|
+
/** Remaining receiver credit for scrub pushes — see {@link SCRUB_CREDIT_CAP}. */
|
|
18455
|
+
scrubCredit = SCRUB_CREDIT_CAP;
|
|
18456
|
+
/**
|
|
18457
|
+
* Frames still unrendered, per outstanding PUSH, oldest first. A jump enqueues
|
|
18458
|
+
* `1`; a sweep enqueues however many predicted frames it put on the wire. Each
|
|
18459
|
+
* `scrubAck` settles one frame off the head, and the credit that push spent
|
|
18460
|
+
* comes back only when the head reaches zero — so a sweep cannot be refunded
|
|
18461
|
+
* once per frame for the single credit it cost.
|
|
18462
|
+
*/
|
|
18463
|
+
scrubOutstanding = [];
|
|
18146
18464
|
/** The newest un-processed drag target (null when the tick cycle has drained). */
|
|
18147
18465
|
scrubPendingEpochMs = null;
|
|
18148
18466
|
/**
|
|
@@ -18173,6 +18491,15 @@ var RecordedFeeder = class {
|
|
|
18173
18491
|
* sign of `(newTarget − this)` is the drag direction that steers prefetch.
|
|
18174
18492
|
*/
|
|
18175
18493
|
scrubPrevTarget = null;
|
|
18494
|
+
/** The last AU actually pushed this drag — the sweep-continuity cursor
|
|
18495
|
+
* (which fragment, and how deep into its AU list). Null until a jump lands. */
|
|
18496
|
+
scrubCursor = null;
|
|
18497
|
+
/** Per-drag mode accounting for the `scrub-feeder:drag-end` summary. */
|
|
18498
|
+
scrubJumps = 0;
|
|
18499
|
+
scrubSweeps = 0;
|
|
18500
|
+
scrubAusPushed = 0;
|
|
18501
|
+
/** Once-per-drag guard: a dead-zone drag ticks fast; one line says it all. */
|
|
18502
|
+
scrubNoFootageLogged = false;
|
|
18176
18503
|
/**
|
|
18177
18504
|
* Signed run-length of consecutive same-direction `scrubTo` deltas (+ forward,
|
|
18178
18505
|
* − backward). A prefetch commits only once `|run| ≥ SCRUB_PREFETCH_DIR_THRESHOLD`;
|
|
@@ -18210,9 +18537,19 @@ var RecordedFeeder = class {
|
|
|
18210
18537
|
async seek(epochMs) {
|
|
18211
18538
|
const token = ++this.seekToken;
|
|
18212
18539
|
if (this.scrubbing) {
|
|
18540
|
+
this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
18541
|
+
jumps: this.scrubJumps,
|
|
18542
|
+
sweeps: this.scrubSweeps,
|
|
18543
|
+
aus: this.scrubAusPushed
|
|
18544
|
+
});
|
|
18213
18545
|
this.scrubbing = false;
|
|
18214
18546
|
this.scrubToken++;
|
|
18215
18547
|
this.scrubSegment = null;
|
|
18548
|
+
this.scrubCursor = null;
|
|
18549
|
+
this.scrubJumps = 0;
|
|
18550
|
+
this.scrubSweeps = 0;
|
|
18551
|
+
this.scrubAusPushed = 0;
|
|
18552
|
+
this.scrubOutstanding = [];
|
|
18216
18553
|
this.scrubCache.clear();
|
|
18217
18554
|
this.scrubPrevTarget = null;
|
|
18218
18555
|
this.scrubDirRun = 0;
|
|
@@ -18223,29 +18560,67 @@ var RecordedFeeder = class {
|
|
|
18223
18560
|
}
|
|
18224
18561
|
this.advanceToken++;
|
|
18225
18562
|
this.inFlightSeekEpochMs = epochMs;
|
|
18563
|
+
let snapInfo = null;
|
|
18226
18564
|
try {
|
|
18227
18565
|
this.deps.onState("loading");
|
|
18228
18566
|
if (this.clockHasAdvanced) {
|
|
18229
18567
|
this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18230
18568
|
this.audioClock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18231
18569
|
}
|
|
18232
|
-
|
|
18570
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
18233
18571
|
deviceId: this.deps.deviceId,
|
|
18234
18572
|
profile: this.deps.profile,
|
|
18235
18573
|
epochMs
|
|
18236
18574
|
}));
|
|
18237
18575
|
if (this.disposed || token !== this.seekToken) return;
|
|
18576
|
+
let landMs = epochMs;
|
|
18238
18577
|
if (loc.kind === "gap") {
|
|
18239
|
-
|
|
18240
|
-
|
|
18241
|
-
|
|
18242
|
-
|
|
18243
|
-
|
|
18578
|
+
const decision = resolveSnapTarget(loc, epochMs);
|
|
18579
|
+
if (decision.kind !== "snap") {
|
|
18580
|
+
if (decision.kind === "too-far") this.deps.onLog?.("recorded:snap-too-far", {
|
|
18581
|
+
requestedMs: epochMs,
|
|
18582
|
+
nearestMs: decision.snapMs,
|
|
18583
|
+
distanceMs: decision.distanceMs
|
|
18584
|
+
});
|
|
18585
|
+
else this.deps.onLog?.("recorded:seek-no-footage", { requestedMs: epochMs });
|
|
18586
|
+
this.currentSegment = null;
|
|
18587
|
+
this.lastFedVideoIdx = -1;
|
|
18588
|
+
this.stopPositionReports();
|
|
18589
|
+
this.deps.onState("gap");
|
|
18590
|
+
return;
|
|
18591
|
+
}
|
|
18592
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
18593
|
+
deviceId: this.deps.deviceId,
|
|
18594
|
+
profile: this.deps.profile,
|
|
18595
|
+
epochMs: decision.snapMs
|
|
18596
|
+
}));
|
|
18597
|
+
if (this.disposed || token !== this.seekToken) return;
|
|
18598
|
+
if (snapped.kind !== "segment") {
|
|
18599
|
+
this.deps.onLog?.("recorded:seek-no-footage", {
|
|
18600
|
+
requestedMs: epochMs,
|
|
18601
|
+
snappedMs: decision.snapMs
|
|
18602
|
+
});
|
|
18603
|
+
this.currentSegment = null;
|
|
18604
|
+
this.lastFedVideoIdx = -1;
|
|
18605
|
+
this.stopPositionReports();
|
|
18606
|
+
this.deps.onState("gap");
|
|
18607
|
+
return;
|
|
18608
|
+
}
|
|
18609
|
+
this.deps.onLog?.("recorded:seek-snapped", {
|
|
18610
|
+
requestedMs: epochMs,
|
|
18611
|
+
snappedMs: decision.snapMs
|
|
18612
|
+
});
|
|
18613
|
+
loc = snapped;
|
|
18614
|
+
landMs = decision.snapMs;
|
|
18615
|
+
snapInfo = {
|
|
18616
|
+
requestedMs: epochMs,
|
|
18617
|
+
snappedMs: decision.snapMs
|
|
18618
|
+
};
|
|
18244
18619
|
}
|
|
18245
18620
|
this.cursorMs = loc.startMs;
|
|
18246
18621
|
const loaded = await this.loadAus(loc.startMs, this.rate !== 0);
|
|
18247
18622
|
if (this.disposed || token !== this.seekToken || loaded === null) return;
|
|
18248
|
-
this.feedPrefix(loc, loaded.video,
|
|
18623
|
+
this.feedPrefix(loc, loaded.video, landMs);
|
|
18249
18624
|
if (this.rate === 0) {
|
|
18250
18625
|
this.deps.onState("paused");
|
|
18251
18626
|
this.deps.onPosition(this.cursorMs);
|
|
@@ -18265,9 +18640,20 @@ var RecordedFeeder = class {
|
|
|
18265
18640
|
},
|
|
18266
18641
|
resumeAfterPtsMs: lastFedAu ? lastFedAu.ptsMs : null
|
|
18267
18642
|
});
|
|
18268
|
-
} catch {
|
|
18643
|
+
} catch (err) {
|
|
18269
18644
|
if (!this.disposed && token === this.seekToken) {
|
|
18270
18645
|
this.stopPositionReports();
|
|
18646
|
+
if (snapInfo !== null) {
|
|
18647
|
+
this.deps.onLog?.("recorded:seek-snap-load-failed", {
|
|
18648
|
+
requestedMs: snapInfo.requestedMs,
|
|
18649
|
+
snappedMs: snapInfo.snappedMs,
|
|
18650
|
+
error: errMsg(err)
|
|
18651
|
+
});
|
|
18652
|
+
this.currentSegment = null;
|
|
18653
|
+
this.lastFedVideoIdx = -1;
|
|
18654
|
+
this.deps.onState("gap");
|
|
18655
|
+
return;
|
|
18656
|
+
}
|
|
18271
18657
|
this.deps.onState("error");
|
|
18272
18658
|
}
|
|
18273
18659
|
} finally {
|
|
@@ -18322,8 +18708,15 @@ var RecordedFeeder = class {
|
|
|
18322
18708
|
this.scrubToken++;
|
|
18323
18709
|
this.scrubPendingEpochMs = null;
|
|
18324
18710
|
this.scrubSegment = null;
|
|
18711
|
+
this.scrubCredit = SCRUB_CREDIT_CAP;
|
|
18325
18712
|
this.scrubPrevTarget = null;
|
|
18326
18713
|
this.scrubDirRun = 0;
|
|
18714
|
+
this.scrubCursor = null;
|
|
18715
|
+
this.scrubJumps = 0;
|
|
18716
|
+
this.scrubSweeps = 0;
|
|
18717
|
+
this.scrubAusPushed = 0;
|
|
18718
|
+
this.scrubNoFootageLogged = false;
|
|
18719
|
+
this.scrubOutstanding = [];
|
|
18327
18720
|
if (this.scrubTimer) {
|
|
18328
18721
|
clearTimeout(this.scrubTimer);
|
|
18329
18722
|
this.scrubTimer = null;
|
|
@@ -18343,6 +18736,24 @@ var RecordedFeeder = class {
|
|
|
18343
18736
|
if (this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
18344
18737
|
}
|
|
18345
18738
|
/**
|
|
18739
|
+
* Receiver credit: the viewer RENDERED a scrub frame. Restores one push
|
|
18740
|
+
* credit (capped — over-acking never banks a burst) and, when a target sat
|
|
18741
|
+
* parked on an exhausted budget, resumes the tick. This — not any timer — is
|
|
18742
|
+
* what paces scrub pushes to what the link actually delivers.
|
|
18743
|
+
*/
|
|
18744
|
+
scrubAck() {
|
|
18745
|
+
if (this.disposed) return;
|
|
18746
|
+
const head = this.scrubOutstanding[0];
|
|
18747
|
+
if (head === void 0) return;
|
|
18748
|
+
if (head > 1) {
|
|
18749
|
+
this.scrubOutstanding[0] = head - 1;
|
|
18750
|
+
return;
|
|
18751
|
+
}
|
|
18752
|
+
this.scrubOutstanding.shift();
|
|
18753
|
+
this.scrubCredit = Math.min(SCRUB_CREDIT_CAP, this.scrubCredit + 1);
|
|
18754
|
+
if (this.scrubbing && this.scrubPendingEpochMs !== null && this.scrubTimer === null) this.scrubTimer = setTimeout(() => void this.scrubTick(), 0);
|
|
18755
|
+
}
|
|
18756
|
+
/**
|
|
18346
18757
|
* One coalesced feeder-scrub push. Captures the newest pending target + the
|
|
18347
18758
|
* current mode generation, resolves the covering fragment (reusing the memo'd
|
|
18348
18759
|
* one when the target stays inside it), picks the nearest keyframe ≤ the
|
|
@@ -18353,25 +18764,68 @@ var RecordedFeeder = class {
|
|
|
18353
18764
|
*/
|
|
18354
18765
|
async scrubTick() {
|
|
18355
18766
|
const gen = this.scrubToken;
|
|
18767
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) {
|
|
18768
|
+
this.scrubTimer = null;
|
|
18769
|
+
return;
|
|
18770
|
+
}
|
|
18771
|
+
if (this.scrubCredit <= 0) {
|
|
18772
|
+
this.scrubTimer = null;
|
|
18773
|
+
this.deps.onLog?.("scrub-feeder:parked", { pendingEpochMs: this.scrubPendingEpochMs });
|
|
18774
|
+
return;
|
|
18775
|
+
}
|
|
18356
18776
|
const target = this.scrubPendingEpochMs;
|
|
18357
18777
|
this.scrubPendingEpochMs = null;
|
|
18358
|
-
if (
|
|
18778
|
+
if (target === null) {
|
|
18359
18779
|
this.scrubTimer = null;
|
|
18360
18780
|
return;
|
|
18361
18781
|
}
|
|
18362
18782
|
try {
|
|
18363
|
-
|
|
18783
|
+
let loc = await this.withTimeout(this.deps.locate({
|
|
18364
18784
|
deviceId: this.deps.deviceId,
|
|
18365
18785
|
profile: this.deps.profile,
|
|
18366
18786
|
epochMs: target
|
|
18367
18787
|
}));
|
|
18368
18788
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18369
|
-
|
|
18789
|
+
let effTarget = target;
|
|
18790
|
+
if (loc.kind === "gap") {
|
|
18791
|
+
const decision = resolveSnapTarget(loc, target);
|
|
18792
|
+
if (decision.kind !== "snap") {
|
|
18793
|
+
if (!this.scrubNoFootageLogged) {
|
|
18794
|
+
this.scrubNoFootageLogged = true;
|
|
18795
|
+
if (decision.kind === "too-far") this.deps.onLog?.("scrub-feeder:snap-too-far", {
|
|
18796
|
+
epochMs: target,
|
|
18797
|
+
nearestMs: decision.snapMs,
|
|
18798
|
+
distanceMs: decision.distanceMs
|
|
18799
|
+
});
|
|
18800
|
+
else this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
18801
|
+
}
|
|
18802
|
+
return;
|
|
18803
|
+
}
|
|
18804
|
+
const snapped = await this.withTimeout(this.deps.locate({
|
|
18805
|
+
deviceId: this.deps.deviceId,
|
|
18806
|
+
profile: this.deps.profile,
|
|
18807
|
+
epochMs: decision.snapMs
|
|
18808
|
+
}));
|
|
18809
|
+
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18810
|
+
if (snapped.kind !== "segment") {
|
|
18811
|
+
if (!this.scrubNoFootageLogged) {
|
|
18812
|
+
this.scrubNoFootageLogged = true;
|
|
18813
|
+
this.deps.onLog?.("scrub-feeder:no-footage", { epochMs: target });
|
|
18814
|
+
}
|
|
18815
|
+
return;
|
|
18816
|
+
}
|
|
18817
|
+
this.deps.onLog?.("scrub-feeder:snapped", {
|
|
18818
|
+
requestedMs: target,
|
|
18819
|
+
snappedMs: decision.snapMs
|
|
18820
|
+
});
|
|
18821
|
+
loc = snapped;
|
|
18822
|
+
effTarget = decision.snapMs;
|
|
18823
|
+
}
|
|
18370
18824
|
let seg = this.scrubSegment;
|
|
18371
18825
|
if (!seg || seg.startMs !== loc.startMs) {
|
|
18372
18826
|
seg = this.scrubCacheGet(loc.startMs);
|
|
18373
18827
|
if (!seg) {
|
|
18374
|
-
const loaded = await this.loadAus(loc.startMs, false
|
|
18828
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
18375
18829
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
18376
18830
|
seg = {
|
|
18377
18831
|
startMs: loc.startMs,
|
|
@@ -18382,26 +18836,80 @@ var RecordedFeeder = class {
|
|
|
18382
18836
|
}
|
|
18383
18837
|
this.scrubSegment = seg;
|
|
18384
18838
|
}
|
|
18385
|
-
const
|
|
18386
|
-
|
|
18387
|
-
|
|
18388
|
-
|
|
18839
|
+
const prevTarget = this.scrubPrevTarget;
|
|
18840
|
+
const cursor = this.scrubCursor;
|
|
18841
|
+
const targetIdx = this.pickScrubIndex(seg, effTarget, false);
|
|
18842
|
+
const forwardDelta = prevTarget === null ? null : effTarget - prevTarget;
|
|
18843
|
+
const withinWindow = forwardDelta !== null && forwardDelta > 0 && forwardDelta <= SCRUB_SWEEP_WINDOW_MS;
|
|
18844
|
+
const sameFragment = cursor !== null && cursor.fragStartMs === seg.startMs;
|
|
18845
|
+
const adjacentForward = cursor !== null && seg.startMs === cursor.fragStartMs + cursor.fragDurMs;
|
|
18846
|
+
let aus;
|
|
18847
|
+
let firstIdx;
|
|
18848
|
+
let mode;
|
|
18849
|
+
let crossesFragment = false;
|
|
18850
|
+
if (withinWindow && sameFragment && cursor !== null && targetIdx > cursor.idx) {
|
|
18851
|
+
mode = "sweep";
|
|
18852
|
+
firstIdx = cursor.idx + 1;
|
|
18853
|
+
aus = seg.aus.slice(firstIdx, targetIdx + 1);
|
|
18854
|
+
} else if (withinWindow && adjacentForward) {
|
|
18855
|
+
mode = "sweep";
|
|
18856
|
+
firstIdx = 0;
|
|
18857
|
+
aus = seg.aus.slice(0, targetIdx + 1);
|
|
18858
|
+
crossesFragment = true;
|
|
18859
|
+
} else {
|
|
18860
|
+
mode = "jump";
|
|
18861
|
+
firstIdx = this.pickScrubIndex(seg, effTarget, true);
|
|
18862
|
+
const kf = seg.aus[firstIdx];
|
|
18863
|
+
aus = kf === void 0 ? [] : [kf];
|
|
18864
|
+
}
|
|
18865
|
+
const lastAu = aus[aus.length - 1];
|
|
18866
|
+
if (lastAu === void 0) {
|
|
18867
|
+
this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
|
|
18868
|
+
this.scrubPrevTarget = effTarget;
|
|
18869
|
+
return;
|
|
18870
|
+
}
|
|
18871
|
+
if (mode === "jump" && this.clockHasAdvanced) this.clock.markDiscontinuity(SEEK_DISCONTINUITY_MS);
|
|
18872
|
+
else if (crossesFragment && this.clockHasAdvanced) this.clock.markSeek();
|
|
18873
|
+
for (const au of aus) this.deps.sink.pushAccessUnit(au.data, this.clock.stamp(au.ptsMs), au.codec);
|
|
18874
|
+
this.scrubOutstanding.push(aus.length);
|
|
18875
|
+
this.scrubCredit -= 1;
|
|
18389
18876
|
this.clockHasAdvanced = true;
|
|
18390
|
-
|
|
18391
|
-
this.
|
|
18392
|
-
|
|
18393
|
-
|
|
18877
|
+
if (mode === "jump") this.scrubJumps += 1;
|
|
18878
|
+
else this.scrubSweeps += 1;
|
|
18879
|
+
this.scrubAusPushed += aus.length;
|
|
18880
|
+
this.scrubCursor = {
|
|
18881
|
+
fragStartMs: seg.startMs,
|
|
18882
|
+
fragDurMs: seg.durMs,
|
|
18883
|
+
idx: firstIdx + aus.length - 1
|
|
18884
|
+
};
|
|
18885
|
+
this.deps.onLog?.("scrub-feeder:pushed", {
|
|
18886
|
+
epochMs: effTarget,
|
|
18887
|
+
mode,
|
|
18888
|
+
aus: aus.length,
|
|
18889
|
+
codec: lastAu.codec,
|
|
18890
|
+
bytes: lastAu.data.length,
|
|
18891
|
+
creditLeft: this.scrubCredit
|
|
18892
|
+
});
|
|
18893
|
+
this.deps.onPosition(this.clampIntoSegment(seg, effTarget));
|
|
18894
|
+
this.maybeScrubPrefetch(seg, effTarget, gen);
|
|
18895
|
+
} catch (err) {
|
|
18896
|
+
this.deps.onLog?.("scrub-feeder:tick-failed", {
|
|
18897
|
+
epochMs: target,
|
|
18898
|
+
error: err instanceof Error ? err.message : String(err)
|
|
18899
|
+
});
|
|
18900
|
+
} finally {
|
|
18901
|
+
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
18902
|
else this.scrubTimer = null;
|
|
18395
18903
|
}
|
|
18396
18904
|
}
|
|
18397
18905
|
/**
|
|
18398
|
-
* Index of the nearest
|
|
18906
|
+
* Index of the nearest AU at-or-before the drag target within `seg`
|
|
18399
18907
|
* (default 0 — the fragment's params-fused IDR, which the demux contract
|
|
18400
|
-
* guarantees).
|
|
18401
|
-
*
|
|
18402
|
-
*
|
|
18908
|
+
* guarantees). `keyframesOnly` restricts the pick to sync frames — the JUMP
|
|
18909
|
+
* mode, whose pushed frame must be standalone-decodable; sweep mode picks
|
|
18910
|
+
* ANY frame, which is what makes the flow frame-granular.
|
|
18403
18911
|
*/
|
|
18404
|
-
|
|
18912
|
+
pickScrubIndex(seg, target, keyframesOnly) {
|
|
18405
18913
|
const first = seg.aus[0];
|
|
18406
18914
|
if (!first) return 0;
|
|
18407
18915
|
const offsetMs = target - seg.startMs;
|
|
@@ -18410,7 +18918,7 @@ var RecordedFeeder = class {
|
|
|
18410
18918
|
const au = seg.aus[i];
|
|
18411
18919
|
if (!au) continue;
|
|
18412
18920
|
if (au.ptsMs - first.ptsMs > offsetMs) break;
|
|
18413
|
-
if (au.keyframe) idx = i;
|
|
18921
|
+
if (!keyframesOnly || au.keyframe) idx = i;
|
|
18414
18922
|
}
|
|
18415
18923
|
return idx;
|
|
18416
18924
|
}
|
|
@@ -18481,7 +18989,7 @@ var RecordedFeeder = class {
|
|
|
18481
18989
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken) return;
|
|
18482
18990
|
if (loc.kind !== "segment") return;
|
|
18483
18991
|
if (this.scrubCache.has(loc.startMs)) return;
|
|
18484
|
-
const loaded = await this.loadAus(loc.startMs, false
|
|
18992
|
+
const loaded = await this.loadAus(loc.startMs, false);
|
|
18485
18993
|
if (this.disposed || !this.scrubbing || gen !== this.scrubToken || loaded === null) return;
|
|
18486
18994
|
this.scrubCachePut({
|
|
18487
18995
|
startMs: loc.startMs,
|
|
@@ -18831,10 +19339,8 @@ var RecordedFeeder = class {
|
|
|
18831
19339
|
* access units. Keyed on `seekToken` so a fresh seek aborts an in-flight load
|
|
18832
19340
|
* (latest-wins). Returns null on abort. Audio demux failures yield [] (audio
|
|
18833
19341
|
* 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) {
|
|
19342
|
+
* (paused scrubs would throw it away). */
|
|
19343
|
+
async loadAus(startMs, wantAudio = true) {
|
|
18838
19344
|
const token = this.seekToken;
|
|
18839
19345
|
const bytes = await this.withTimeout(this.deps.readBytes({
|
|
18840
19346
|
deviceId: this.deps.deviceId,
|
|
@@ -18842,8 +19348,7 @@ var RecordedFeeder = class {
|
|
|
18842
19348
|
startMs
|
|
18843
19349
|
}));
|
|
18844
19350
|
if (this.disposed || token !== this.seekToken) return null;
|
|
18845
|
-
const
|
|
18846
|
-
const video = await this.withTimeout(demux(bytes));
|
|
19351
|
+
const video = await this.withTimeout(this.deps.demux(bytes));
|
|
18847
19352
|
if (this.disposed || token !== this.seekToken) return null;
|
|
18848
19353
|
let audio = [];
|
|
18849
19354
|
if (wantAudio && this.deps.demuxAudio && this.resolveAudioPush() !== void 0) {
|
|
@@ -18889,6 +19394,11 @@ var RecordedFeeder = class {
|
|
|
18889
19394
|
}
|
|
18890
19395
|
/** Stop everything: invalidate in-flight work and tear down the position timer. */
|
|
18891
19396
|
dispose() {
|
|
19397
|
+
if (this.scrubbing) this.deps.onLog?.("scrub-feeder:drag-end", {
|
|
19398
|
+
jumps: this.scrubJumps,
|
|
19399
|
+
sweeps: this.scrubSweeps,
|
|
19400
|
+
aus: this.scrubAusPushed
|
|
19401
|
+
});
|
|
18892
19402
|
this.disposed = true;
|
|
18893
19403
|
this.seekToken++;
|
|
18894
19404
|
this.advanceToken++;
|
|
@@ -18898,6 +19408,12 @@ var RecordedFeeder = class {
|
|
|
18898
19408
|
this.scrubCache.clear();
|
|
18899
19409
|
this.scrubPrevTarget = null;
|
|
18900
19410
|
this.scrubDirRun = 0;
|
|
19411
|
+
this.scrubCursor = null;
|
|
19412
|
+
this.scrubJumps = 0;
|
|
19413
|
+
this.scrubSweeps = 0;
|
|
19414
|
+
this.scrubAusPushed = 0;
|
|
19415
|
+
this.scrubNoFootageLogged = false;
|
|
19416
|
+
this.scrubOutstanding = [];
|
|
18901
19417
|
if (this.scrubTimer) {
|
|
18902
19418
|
clearTimeout(this.scrubTimer);
|
|
18903
19419
|
this.scrubTimer = null;
|
|
@@ -18925,6 +19441,16 @@ var TimelineSession = class {
|
|
|
18925
19441
|
this.deps = deps;
|
|
18926
19442
|
deps.session.setLiveFeedGate(false);
|
|
18927
19443
|
}
|
|
19444
|
+
/**
|
|
19445
|
+
* Which source the track is currently carrying. Read by the broker's
|
|
19446
|
+
* adaptive ladder, which must NOT act while the pointer is off live: a tier
|
|
19447
|
+
* switch is a close-and-recreate re-offer, and that disposes this
|
|
19448
|
+
* coordinator — the viewer silently lands back on live at the wrong moment
|
|
19449
|
+
* with no code path to restore the pointer.
|
|
19450
|
+
*/
|
|
19451
|
+
get currentMode() {
|
|
19452
|
+
return this.mode;
|
|
19453
|
+
}
|
|
18928
19454
|
async handleControl(msg) {
|
|
18929
19455
|
switch (msg.t) {
|
|
18930
19456
|
case "playRecorded":
|
|
@@ -18941,6 +19467,9 @@ var TimelineSession = class {
|
|
|
18941
19467
|
case "scrubTo":
|
|
18942
19468
|
if (this.mode === "recorded") this.feeder?.scrubTo(msg.epoch);
|
|
18943
19469
|
break;
|
|
19470
|
+
case "scrubAck":
|
|
19471
|
+
if (this.mode === "recorded") this.feeder?.scrubAck();
|
|
19472
|
+
break;
|
|
18944
19473
|
case "setRate":
|
|
18945
19474
|
this.feeder?.setRate(msg.rate);
|
|
18946
19475
|
break;
|
|
@@ -18948,6 +19477,7 @@ var TimelineSession = class {
|
|
|
18948
19477
|
if (this.mode === "recorded") this.feeder?.stepFrame(msg.dir);
|
|
18949
19478
|
break;
|
|
18950
19479
|
case "setProfile":
|
|
19480
|
+
if (msg.profile === this.profile) break;
|
|
18951
19481
|
this.profile = msg.profile;
|
|
18952
19482
|
if (this.mode === "recorded") this.enterRecorded();
|
|
18953
19483
|
break;
|
|
@@ -25662,25 +26192,6 @@ async function extractSamples(segment, segmentStartMs) {
|
|
|
25662
26192
|
samples: collected
|
|
25663
26193
|
} : null;
|
|
25664
26194
|
}
|
|
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
26195
|
//#endregion
|
|
25685
26196
|
//#region src/stream-broker/recorded/avcc-annexb.ts
|
|
25686
26197
|
/**
|
|
@@ -26184,33 +26695,6 @@ var SEGMENT_DEMUX_FFMPEG_ARGS = [
|
|
|
26184
26695
|
"pipe:1"
|
|
26185
26696
|
];
|
|
26186
26697
|
/**
|
|
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
26698
|
* Demux a self-contained H.264 or H.265 `.m4s` fragment into ordered Annex-B
|
|
26215
26699
|
* access units.
|
|
26216
26700
|
*
|
|
@@ -26236,113 +26720,35 @@ async function demuxSegmentToAnnexB(bytes, ffmpegPath = "ffmpeg") {
|
|
|
26236
26720
|
codec
|
|
26237
26721
|
}));
|
|
26238
26722
|
}
|
|
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
26723
|
//#endregion
|
|
26276
26724
|
//#region src/stream-broker/recorded/segment-demux-js.ts
|
|
26277
26725
|
/**
|
|
26278
26726
|
* segment-demux-js — PURE-JS in-process demux for recorded playback.
|
|
26279
26727
|
*
|
|
26280
|
-
*
|
|
26281
|
-
*
|
|
26282
|
-
*
|
|
26283
|
-
*
|
|
26284
|
-
* (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
|
|
26728
|
+
* {@link demuxSegmentFullJs} / {@link demuxSegmentPreferJs} — FULL playback
|
|
26729
|
+
* (every access unit, sync + delta), the drop-in for `RecordedFeederDeps.demux`.
|
|
26730
|
+
* Both playback AND scrub demux through this since sweep mode (the scrub train
|
|
26731
|
+
* pushes predicted frames, so the whole GOP is needed either way).
|
|
26285
26732
|
*
|
|
26286
|
-
* Replaces the per-fragment ffmpeg spawn (
|
|
26287
|
-
*
|
|
26733
|
+
* Replaces the per-fragment ffmpeg spawn (a ~140 ms subprocess floor) with an
|
|
26734
|
+
* in-process mp4box parse. A recorded `.m4s`
|
|
26288
26735
|
* is a self-contained fragment (`+frag_keyframe+empty_moov+default_base_moof`)
|
|
26289
26736
|
* carrying its own `moov`, so its `avcC`/`hvcC` parameter sets and its sync
|
|
26290
26737
|
* sample(s) are all in the bytes we already hold — no init segment to fetch.
|
|
26291
26738
|
*
|
|
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.
|
|
26739
|
+
* Flow: {@link extractSamples} (the proven mp4box demux the recorder scrub
|
|
26740
|
+
* module already ships — box parsing is NOT duplicated here) yields the codec
|
|
26741
|
+
* `description` + length-prefixed samples; sync samples are fused with their
|
|
26742
|
+
* parameter sets ({@link buildKeyframeAnnexB}), delta samples converted plain
|
|
26743
|
+
* ({@link avccSampleToAnnexB}). `ptsMs` is segment-relative (anchored to 0 at
|
|
26744
|
+
* the first sample), exactly like the ffmpeg demux's PES-derived deltas —
|
|
26745
|
+
* `clampIntoSegment`/position reporting are unaffected.
|
|
26305
26746
|
*/
|
|
26306
26747
|
/** True for a WebCodecs codec string that is HEVC (`hvc1.`/`hev1.`), else H.264. */
|
|
26307
26748
|
function isHevcCodec(codec) {
|
|
26308
26749
|
return /^h[ev][vc]1\./i.test(codec);
|
|
26309
26750
|
}
|
|
26310
26751
|
/**
|
|
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
26752
|
* Pure-JS FULL-playback demux of one self-contained `.m4s` fragment: return
|
|
26347
26753
|
* EVERY access unit (sync + delta) as params-fused Annex-B with segment-relative
|
|
26348
26754
|
* `ptsMs`, matching the {@link AccessUnit} contract of {@link demuxSegmentToAnnexB}
|
|
@@ -26651,7 +27057,8 @@ var ADAPTIVE_CONFIG = {
|
|
|
26651
27057
|
minFailPercent: .05,
|
|
26652
27058
|
waitAfterResetMs: 8e3,
|
|
26653
27059
|
upgradeHealthyMs: 6e4,
|
|
26654
|
-
minTimeBetweenChangesMs: 6e4
|
|
27060
|
+
minTimeBetweenChangesMs: 6e4,
|
|
27061
|
+
maxUpgradeBackoffMs: 9e5
|
|
26655
27062
|
};
|
|
26656
27063
|
/**
|
|
26657
27064
|
* True when `broker` is an in-process StreamBroker exposing the Opus egress
|
|
@@ -26837,6 +27244,24 @@ var BrokerWebrtcServer = class {
|
|
|
26837
27244
|
/** brokerId → IStreamBroker reference. Set by the broker manager on registration. */
|
|
26838
27245
|
brokers = /* @__PURE__ */ new Map();
|
|
26839
27246
|
/**
|
|
27247
|
+
* Post-session warmth: when the LAST WebRTC session on a broker closes, a
|
|
27248
|
+
* ghost subscription keeps the source dialed and its RTP ring warm for the
|
|
27249
|
+
* linger window, so the dominant remote pattern — reopen within minutes —
|
|
27250
|
+
* reconnects with an instant-start GOP replay instead of re-paying the
|
|
27251
|
+
* cold dial + next-IDR wait. `kind:'warmup'` reuses the existing consumer
|
|
27252
|
+
* attribution enum; the label answers "who keeps cam X warm?" in the
|
|
27253
|
+
* operator widget.
|
|
27254
|
+
*/
|
|
27255
|
+
brokerLinger = new BrokerLingerRegistry({ subscribe: (brokerId) => {
|
|
27256
|
+
const broker = this.brokers.get(brokerId);
|
|
27257
|
+
if (!broker) return null;
|
|
27258
|
+
return broker.onEncodedData(() => {}, {
|
|
27259
|
+
kind: "warmup",
|
|
27260
|
+
label: "webrtc-linger",
|
|
27261
|
+
media: "both"
|
|
27262
|
+
});
|
|
27263
|
+
} });
|
|
27264
|
+
/**
|
|
26840
27265
|
* Resolves a brokerId to its current profile tier (`CamProfile`)
|
|
26841
27266
|
* via the manager's `assignments` map, or `null` for brokers that
|
|
26842
27267
|
* are only kept alive by a manual activation. Injected by the
|
|
@@ -26999,16 +27424,20 @@ var BrokerWebrtcServer = class {
|
|
|
26999
27424
|
* RTC controller / Alexa handler closes the session the same way.
|
|
27000
27425
|
*/
|
|
27001
27426
|
async handleOffer(streamId, clientOfferSdp, hints = {}, opts = {}) {
|
|
27427
|
+
const offerStart = Date.now();
|
|
27002
27428
|
const setup = await this.setupSessionForBroker(streamId, hints, {
|
|
27003
27429
|
...opts,
|
|
27004
27430
|
clientOfferSdp
|
|
27005
27431
|
});
|
|
27432
|
+
const setupMs = Date.now() - offerStart;
|
|
27006
27433
|
try {
|
|
27007
27434
|
if (opts.relayOnly === true) setup.session.setForceRelayOnly(true);
|
|
27435
|
+
const negotiateStart = Date.now();
|
|
27008
27436
|
const answer = await setup.session.handleOffer({
|
|
27009
27437
|
sdp: clientOfferSdp,
|
|
27010
27438
|
type: "offer"
|
|
27011
27439
|
}, { nonTrickle: opts.nonTrickle === true });
|
|
27440
|
+
const negotiateMs = Date.now() - negotiateStart;
|
|
27012
27441
|
setup.sessionLogger.info("WebRTC session created (client-offer)", { meta: {
|
|
27013
27442
|
sessionId: setup.sessionId,
|
|
27014
27443
|
deviceId: setup.deviceId,
|
|
@@ -27016,7 +27445,11 @@ var BrokerWebrtcServer = class {
|
|
|
27016
27445
|
iceServers: setup.iceServerCount,
|
|
27017
27446
|
codec: setup.sessionCodec,
|
|
27018
27447
|
useRtpRepacketizer: setup.useRtpRepacketizer,
|
|
27019
|
-
relayOnly: opts.relayOnly === true
|
|
27448
|
+
relayOnly: opts.relayOnly === true,
|
|
27449
|
+
setupMs,
|
|
27450
|
+
negotiateMs,
|
|
27451
|
+
totalMs: Date.now() - offerStart,
|
|
27452
|
+
...setup.setupTimings
|
|
27020
27453
|
} });
|
|
27021
27454
|
return {
|
|
27022
27455
|
sessionId: setup.sessionId,
|
|
@@ -27043,6 +27476,11 @@ var BrokerWebrtcServer = class {
|
|
|
27043
27476
|
*/
|
|
27044
27477
|
async setupSessionForBroker(streamId, hints, opts) {
|
|
27045
27478
|
if (this.stopped) throw new Error("Server stopped");
|
|
27479
|
+
const setupStart = Date.now();
|
|
27480
|
+
let ensureTierMs = 0;
|
|
27481
|
+
let warmUpMs = 0;
|
|
27482
|
+
let iceResolveMs = 0;
|
|
27483
|
+
let hwAccelMs = 0;
|
|
27046
27484
|
const isAdaptiveStream = streamId.endsWith("/adaptive");
|
|
27047
27485
|
const intent = isAdaptiveStream && opts.sessionId !== void 0 ? this.adaptiveIntent.get(opts.sessionId) : void 0;
|
|
27048
27486
|
const resolveHints = intent !== void 0 ? {
|
|
@@ -27052,10 +27490,13 @@ var BrokerWebrtcServer = class {
|
|
|
27052
27490
|
if (isAdaptiveStream) {
|
|
27053
27491
|
const adaptiveDeviceId = deviceIdFromBrokerId(streamId);
|
|
27054
27492
|
const desiredTier = resolveHints.prefersTier === "high" || resolveHints.prefersTier === "mid" || resolveHints.prefersTier === "low" ? resolveHints.prefersTier : "high";
|
|
27493
|
+
const ensureTierStart = Date.now();
|
|
27055
27494
|
try {
|
|
27056
27495
|
await this.ensureAdaptiveTierBroker?.(adaptiveDeviceId, desiredTier);
|
|
27057
27496
|
} catch (err) {
|
|
27058
27497
|
throw new Error(`Failed to ensure remote adaptive-tier broker for stream "${streamId}" (tier: ${desiredTier}): ${errMsg(err)}`);
|
|
27498
|
+
} finally {
|
|
27499
|
+
ensureTierMs = Date.now() - ensureTierStart;
|
|
27059
27500
|
}
|
|
27060
27501
|
}
|
|
27061
27502
|
const brokerId = this.resolveBrokerId(streamId, resolveHints);
|
|
@@ -27077,6 +27518,7 @@ var BrokerWebrtcServer = class {
|
|
|
27077
27518
|
kind: "warmup",
|
|
27078
27519
|
media: "both"
|
|
27079
27520
|
});
|
|
27521
|
+
const warmUpStart = Date.now();
|
|
27080
27522
|
await new Promise((resolve) => {
|
|
27081
27523
|
const timer = setTimeout(() => {
|
|
27082
27524
|
sessionLogger.warn("broker SDP wait timed out — falling back to default codec", { meta: {
|
|
@@ -27092,9 +27534,11 @@ var BrokerWebrtcServer = class {
|
|
|
27092
27534
|
resolve();
|
|
27093
27535
|
});
|
|
27094
27536
|
});
|
|
27537
|
+
warmUpMs = Date.now() - warmUpStart;
|
|
27095
27538
|
sessionLogger.info("broker warm-up complete", { meta: {
|
|
27096
27539
|
brokerId,
|
|
27097
|
-
detectedCodec: broker.getStats().codec ?? null
|
|
27540
|
+
detectedCodec: broker.getStats().codec ?? null,
|
|
27541
|
+
waitedMs: warmUpMs
|
|
27098
27542
|
} });
|
|
27099
27543
|
}
|
|
27100
27544
|
const brokerCodec = broker.getStats().codec ?? "h264";
|
|
@@ -27278,6 +27722,7 @@ var BrokerWebrtcServer = class {
|
|
|
27278
27722
|
}
|
|
27279
27723
|
}, annexbAttribution);
|
|
27280
27724
|
warmUpUnsubscribe?.();
|
|
27725
|
+
this.brokerLinger.cancel(brokerId);
|
|
27281
27726
|
const preBuffer = broker.getPreBuffer();
|
|
27282
27727
|
const preParamNals = stickyParamSets ? [Buffer.from(stickyParamSets)] : pendingParamNals.length > 0 ? pendingParamNals.map((b) => Buffer.from(b)) : [];
|
|
27283
27728
|
for (const pkt of preBuffer) if (pkt.type === "video") {
|
|
@@ -27304,7 +27749,9 @@ var BrokerWebrtcServer = class {
|
|
|
27304
27749
|
}
|
|
27305
27750
|
});
|
|
27306
27751
|
}
|
|
27752
|
+
const iceResolveStart = Date.now();
|
|
27307
27753
|
const allIceServers = await this.resolveIceServers();
|
|
27754
|
+
iceResolveMs = Date.now() - iceResolveStart;
|
|
27308
27755
|
const iceServers = opts.disableTurn === true ? allIceServers.map((entry) => {
|
|
27309
27756
|
const stunOnly = (Array.isArray(entry.urls) ? entry.urls : [entry.urls]).filter((u) => !u.startsWith("turn:") && !u.startsWith("turns:"));
|
|
27310
27757
|
if (stunOnly.length === 0) return null;
|
|
@@ -27329,13 +27776,16 @@ var BrokerWebrtcServer = class {
|
|
|
27329
27776
|
sourceProfileLevelId,
|
|
27330
27777
|
offeredProfileLevelIds
|
|
27331
27778
|
} });
|
|
27779
|
+
const hwAccelStart = Date.now();
|
|
27780
|
+
const transcodeDecodeHwAccel = await this.getTranscodeDecodeHwAccel();
|
|
27781
|
+
hwAccelMs = Date.now() - hwAccelStart;
|
|
27332
27782
|
session = new AdaptiveSession({
|
|
27333
27783
|
sessionId,
|
|
27334
27784
|
source,
|
|
27335
27785
|
sourceCodec: sessionCodec,
|
|
27336
27786
|
transcodeToBaseline,
|
|
27337
27787
|
transcodeProfile: effectiveTranscodeProfile,
|
|
27338
|
-
transcodeDecodeHwAccel
|
|
27788
|
+
transcodeDecodeHwAccel,
|
|
27339
27789
|
resolveTranscodeSourceUrl: () => this.sourceRestreamUrlResolver?.(brokerId) ?? null,
|
|
27340
27790
|
iceConfig: {
|
|
27341
27791
|
iceServers,
|
|
@@ -27447,7 +27897,6 @@ var BrokerWebrtcServer = class {
|
|
|
27447
27897
|
const locateRecorded = this.locateRecordedSegment;
|
|
27448
27898
|
const readRecorded = this.readRecordedSegmentBytes;
|
|
27449
27899
|
const resolveFfmpeg = this.resolveFfmpegPath;
|
|
27450
|
-
let scrubJsFallbackLogged = false;
|
|
27451
27900
|
let fullJsFallbackLogged = false;
|
|
27452
27901
|
let audioOpusFallbackLogged = false;
|
|
27453
27902
|
if (locateRecorded && readRecorded) {
|
|
@@ -27475,19 +27924,6 @@ var BrokerWebrtcServer = class {
|
|
|
27475
27924
|
} });
|
|
27476
27925
|
}
|
|
27477
27926
|
}),
|
|
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
27927
|
demuxAudio: opusSession ? async (b) => {
|
|
27492
27928
|
return (await demuxSegmentAudioOpusPreferJs(b, {
|
|
27493
27929
|
ffmpegPath: resolveFfmpeg?.(),
|
|
@@ -27523,10 +27959,17 @@ var BrokerWebrtcServer = class {
|
|
|
27523
27959
|
} });
|
|
27524
27960
|
timeline._emitState(s);
|
|
27525
27961
|
},
|
|
27526
|
-
onPosition: (epochMs) => timeline._emitPosition(epochMs)
|
|
27962
|
+
onPosition: (epochMs) => timeline._emitPosition(epochMs),
|
|
27963
|
+
onLog: (msg, data) => sessionLogger.info(msg, { meta: {
|
|
27964
|
+
sessionId,
|
|
27965
|
+
deviceId,
|
|
27966
|
+
profile,
|
|
27967
|
+
...data
|
|
27968
|
+
} })
|
|
27527
27969
|
});
|
|
27528
27970
|
}
|
|
27529
27971
|
});
|
|
27972
|
+
entry.timeline = timeline;
|
|
27530
27973
|
sessionLogger.info("recorded: TimelineSession wired (control accessors present)", { meta: {
|
|
27531
27974
|
sessionId,
|
|
27532
27975
|
deviceId
|
|
@@ -27576,7 +28019,7 @@ var BrokerWebrtcServer = class {
|
|
|
27576
28019
|
};
|
|
27577
28020
|
if (streamId.endsWith("/adaptive")) {
|
|
27578
28021
|
const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
|
|
27579
|
-
this.startAdaptive(entry, deviceId, initialTier, sessionId);
|
|
28022
|
+
this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures);
|
|
27580
28023
|
}
|
|
27581
28024
|
return {
|
|
27582
28025
|
sessionId,
|
|
@@ -27586,7 +28029,14 @@ var BrokerWebrtcServer = class {
|
|
|
27586
28029
|
brokerId,
|
|
27587
28030
|
sessionCodec,
|
|
27588
28031
|
useRtpRepacketizer,
|
|
27589
|
-
iceServerCount: iceServers.length
|
|
28032
|
+
iceServerCount: iceServers.length,
|
|
28033
|
+
setupTimings: {
|
|
28034
|
+
setupTotalMs: Date.now() - setupStart,
|
|
28035
|
+
ensureTierMs,
|
|
28036
|
+
warmUpMs,
|
|
28037
|
+
iceResolveMs,
|
|
28038
|
+
hwAccelMs
|
|
28039
|
+
}
|
|
27590
28040
|
};
|
|
27591
28041
|
}
|
|
27592
28042
|
async handleAnswer(sessionId, sdpAnswer) {
|
|
@@ -27636,6 +28086,7 @@ var BrokerWebrtcServer = class {
|
|
|
27636
28086
|
async stop() {
|
|
27637
28087
|
if (this.stopped) return;
|
|
27638
28088
|
this.stopped = true;
|
|
28089
|
+
this.brokerLinger.stop();
|
|
27639
28090
|
const closes = [];
|
|
27640
28091
|
for (const [sid, entry] of this.sessions) {
|
|
27641
28092
|
this.cleanupSession(sid);
|
|
@@ -27670,9 +28121,10 @@ var BrokerWebrtcServer = class {
|
|
|
27670
28121
|
* whichever produces the higher loss ratio across its window. The async
|
|
27671
28122
|
* poll is guarded against overlapping ticks via `tickInFlight`.
|
|
27672
28123
|
*/
|
|
27673
|
-
startAdaptive(entry, deviceId, initialTier, sessionId) {
|
|
28124
|
+
startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures) {
|
|
27674
28125
|
const controller = new AdaptiveController({
|
|
27675
28126
|
initialTier,
|
|
28127
|
+
priorFailures,
|
|
27676
28128
|
config: ADAPTIVE_CONFIG,
|
|
27677
28129
|
onSwitchTier: (tier) => {
|
|
27678
28130
|
this.switchSessionTier(entry, deviceId, tier, sessionId);
|
|
@@ -27692,6 +28144,10 @@ var BrokerWebrtcServer = class {
|
|
|
27692
28144
|
tickInFlight = true;
|
|
27693
28145
|
(async () => {
|
|
27694
28146
|
try {
|
|
28147
|
+
if (entry.timeline?.currentMode === "recorded") {
|
|
28148
|
+
controller.suspend(Date.now());
|
|
28149
|
+
return;
|
|
28150
|
+
}
|
|
27695
28151
|
const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
|
|
27696
28152
|
const now = Date.now();
|
|
27697
28153
|
if (pct !== null && pct !== void 0 && pct > 0) {
|
|
@@ -27723,9 +28179,11 @@ var BrokerWebrtcServer = class {
|
|
|
27723
28179
|
*/
|
|
27724
28180
|
switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
|
|
27725
28181
|
const prev = this.adaptiveIntent.get(sessionId);
|
|
28182
|
+
const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
|
|
27726
28183
|
this.adaptiveIntent.set(sessionId, {
|
|
27727
28184
|
prefersTier: tier,
|
|
27728
|
-
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false)
|
|
28185
|
+
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
|
|
28186
|
+
failures
|
|
27729
28187
|
});
|
|
27730
28188
|
const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
|
|
27731
28189
|
entry.session.pendingRenegotiation = {
|
|
@@ -27739,7 +28197,8 @@ var BrokerWebrtcServer = class {
|
|
|
27739
28197
|
deviceId,
|
|
27740
28198
|
tier,
|
|
27741
28199
|
forceTranscodeDown,
|
|
27742
|
-
epoch
|
|
28200
|
+
epoch,
|
|
28201
|
+
failures: Object.fromEntries(failures)
|
|
27743
28202
|
}
|
|
27744
28203
|
});
|
|
27745
28204
|
}
|
|
@@ -27747,6 +28206,12 @@ var BrokerWebrtcServer = class {
|
|
|
27747
28206
|
const entry = this.sessions.get(sessionId);
|
|
27748
28207
|
if (!entry) return;
|
|
27749
28208
|
this.sessions.delete(sessionId);
|
|
28209
|
+
let brokerStillServed = false;
|
|
28210
|
+
for (const other of this.sessions.values()) if (other.brokerId === entry.brokerId) {
|
|
28211
|
+
brokerStillServed = true;
|
|
28212
|
+
break;
|
|
28213
|
+
}
|
|
28214
|
+
if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
|
|
27750
28215
|
this.adaptiveIntent.delete(sessionId);
|
|
27751
28216
|
if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
|
|
27752
28217
|
if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
|
|
@@ -28330,6 +28795,42 @@ var WebrtcSessionProvider = class WebrtcSessionProvider {
|
|
|
28330
28795
|
}
|
|
28331
28796
|
};
|
|
28332
28797
|
//#endregion
|
|
28798
|
+
//#region src/stream-broker/event-loop-stall-monitor.ts
|
|
28799
|
+
var DEFAULT_INTERVAL_MS = 100;
|
|
28800
|
+
var DEFAULT_THRESHOLD_MS = 250;
|
|
28801
|
+
var EventLoopStallMonitor = class {
|
|
28802
|
+
intervalMs;
|
|
28803
|
+
thresholdMs;
|
|
28804
|
+
now;
|
|
28805
|
+
onStall;
|
|
28806
|
+
timer = null;
|
|
28807
|
+
lastTickAt = 0;
|
|
28808
|
+
constructor(options) {
|
|
28809
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
28810
|
+
this.thresholdMs = options.thresholdMs ?? DEFAULT_THRESHOLD_MS;
|
|
28811
|
+
this.now = options.now ?? (() => Date.now());
|
|
28812
|
+
this.onStall = options.onStall;
|
|
28813
|
+
}
|
|
28814
|
+
start() {
|
|
28815
|
+
if (this.timer) return;
|
|
28816
|
+
this.lastTickAt = this.now();
|
|
28817
|
+
this.timer = setInterval(() => {
|
|
28818
|
+
const at = this.now();
|
|
28819
|
+
const lateMs = at - this.lastTickAt - this.intervalMs;
|
|
28820
|
+
this.lastTickAt = at;
|
|
28821
|
+
if (lateMs >= this.thresholdMs) try {
|
|
28822
|
+
this.onStall(lateMs);
|
|
28823
|
+
} catch {}
|
|
28824
|
+
}, this.intervalMs);
|
|
28825
|
+
this.timer.unref?.();
|
|
28826
|
+
}
|
|
28827
|
+
stop() {
|
|
28828
|
+
if (!this.timer) return;
|
|
28829
|
+
clearInterval(this.timer);
|
|
28830
|
+
this.timer = null;
|
|
28831
|
+
}
|
|
28832
|
+
};
|
|
28833
|
+
//#endregion
|
|
28333
28834
|
//#region src/stream-broker/addon.ts
|
|
28334
28835
|
/**
|
|
28335
28836
|
* Hydrate the persisted RTSP restream state (`rtspTokens` / `rtspEnabled`)
|
|
@@ -28422,6 +28923,8 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
28422
28923
|
webrtcServer = null;
|
|
28423
28924
|
/** Handle for the embed SPA data-plane listener (disposed on shutdown). */
|
|
28424
28925
|
embedDataPlane = null;
|
|
28926
|
+
/** Names this process's event-loop stalls — see onInitialize. */
|
|
28927
|
+
loopStallMonitor = null;
|
|
28425
28928
|
/**
|
|
28426
28929
|
* Snapshot-equality cache for the broker-metrics emit. Each
|
|
28427
28930
|
* broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
|
|
@@ -28522,6 +29025,10 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
28522
29025
|
}
|
|
28523
29026
|
}
|
|
28524
29027
|
async onInitialize() {
|
|
29028
|
+
this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
|
|
29029
|
+
this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
|
|
29030
|
+
} });
|
|
29031
|
+
this.loopStallMonitor.start();
|
|
28525
29032
|
this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
|
|
28526
29033
|
this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
|
|
28527
29034
|
this.brokerManager.setEventBus(this.ctx.eventBus);
|
|
@@ -28864,6 +29371,8 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
28864
29371
|
];
|
|
28865
29372
|
}
|
|
28866
29373
|
async onShutdown() {
|
|
29374
|
+
this.loopStallMonitor?.stop();
|
|
29375
|
+
this.loopStallMonitor = null;
|
|
28867
29376
|
this.flushProbeSnapshots(Date.now(), true);
|
|
28868
29377
|
if (this.metricsSnapshotTimer) {
|
|
28869
29378
|
clearInterval(this.metricsSnapshotTimer);
|