@camstack/addon-pipeline 1.2.257 → 1.2.259
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-BTzxxm-z.mjs → default-detection-model-BZ54sUY2.mjs} +1 -1
- package/dist/{default-detection-model-C8CHpxQl.js → default-detection-model-vVWcdv0g.js} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-BMARLLlX.js → dist-CsJJPDQW.js} +154 -1
- package/dist/{dist-xsaGDCOu.mjs → dist-D3w2fwD4.mjs} +154 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-Ca-vE0xX.js → node-1vbEWzPZ.js} +1 -1
- package/dist/{node-Fv4ByT36.mjs → node-IHGNrWH9.mjs} +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +4 -4
- package/dist/{process-memory-C3Wjmvm0.js → process-memory-DynU4177.js} +1 -1
- package/dist/{process-memory-BgtQADAY.mjs → process-memory-jhOBJEil.mjs} +1 -1
- package/dist/recorder/index.js +293 -19
- package/dist/recorder/index.mjs +293 -19
- package/dist/{restream-intent-D2VT97-U.mjs → restream-intent-CW79dvFV.mjs} +118 -2
- package/dist/{restream-intent-VVJiUAkh.js → restream-intent-iKGCB9IR.js} +135 -1
- package/dist/{segment-demux-js-C7C6c-g2.mjs → segment-demux-js-BZAiu_2h.mjs} +1 -1
- package/dist/{segment-demux-js-DHV7EOQf.js → segment-demux-js-OQT6wqXH.js} +1 -1
- package/dist/stream-broker/_stub.js +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CJWLoj3Z.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CFcO9OoY.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CTHexfai.mjs +26 -0
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-BzBcvawL.mjs → hostInit-rU23F6nl.mjs} +3 -3
- package/dist/stream-broker/index.js +476 -17
- package/dist/stream-broker/index.mjs +476 -17
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ReCGmeOG.mjs +0 -26
|
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
|
-
const require_dist = require("../dist-
|
|
7
|
-
const require_node = require("../node-
|
|
8
|
-
const require_restream_intent = require("../restream-intent-
|
|
6
|
+
const require_dist = require("../dist-CsJJPDQW.js");
|
|
7
|
+
const require_node = require("../node-1vbEWzPZ.js");
|
|
8
|
+
const require_restream_intent = require("../restream-intent-iKGCB9IR.js");
|
|
9
9
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
10
10
|
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
11
|
-
const require_segment_demux_js = require("../segment-demux-js-
|
|
11
|
+
const require_segment_demux_js = require("../segment-demux-js-OQT6wqXH.js");
|
|
12
12
|
let node_os = require("node:os");
|
|
13
13
|
node_os = require_chunk.__toESM(node_os);
|
|
14
14
|
let node_crypto = require("node:crypto");
|
|
@@ -2550,6 +2550,218 @@ function deviceIdFromBrokerId$1(brokerId) {
|
|
|
2550
2550
|
const parsed = Number(brokerId.split("/")[0]);
|
|
2551
2551
|
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2552
2552
|
}
|
|
2553
|
+
//#endregion
|
|
2554
|
+
//#region src/stream-broker/rtsp/audio/g711-silence.ts
|
|
2555
|
+
var SILENCE_BYTE = {
|
|
2556
|
+
/** µ-law digital zero. */
|
|
2557
|
+
PCMU: 255,
|
|
2558
|
+
/** A-law digital zero. */
|
|
2559
|
+
PCMA: 213
|
|
2560
|
+
};
|
|
2561
|
+
var STATIC_PAYLOAD_TYPE = {
|
|
2562
|
+
PCMU: 0,
|
|
2563
|
+
PCMA: 8
|
|
2564
|
+
};
|
|
2565
|
+
/** G.711 is 8 kHz mono by definition (RFC 3551). */
|
|
2566
|
+
var G711_CLOCK_RATE_HZ = 8e3;
|
|
2567
|
+
var G711_CHANNELS = 1;
|
|
2568
|
+
/**
|
|
2569
|
+
* The track for a codec name as the camera's SDP spelled it, or `null`.
|
|
2570
|
+
*
|
|
2571
|
+
* `null` is a REFUSAL with a reason the caller must log, never a fallback to
|
|
2572
|
+
* PCMU: a sentry filled with the wrong codec's bytes is audible garbage on
|
|
2573
|
+
* every consumer, which is strictly worse than the silence of no track at all.
|
|
2574
|
+
*/
|
|
2575
|
+
function g711TrackFor(codec) {
|
|
2576
|
+
if (codec === void 0) return null;
|
|
2577
|
+
const upper = codec.toUpperCase();
|
|
2578
|
+
if (upper !== "PCMU" && upper !== "PCMA") return null;
|
|
2579
|
+
return {
|
|
2580
|
+
codec: upper,
|
|
2581
|
+
payloadType: STATIC_PAYLOAD_TYPE[upper],
|
|
2582
|
+
clockRateHz: G711_CLOCK_RATE_HZ,
|
|
2583
|
+
channels: G711_CHANNELS
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* The `m=audio` block grafted onto the sentry's video-only SDP.
|
|
2588
|
+
*
|
|
2589
|
+
* Built rather than extracted from the camera's SDP on purpose. A static
|
|
2590
|
+
* payload type makes the description fully determined by the codec, so the
|
|
2591
|
+
* block is correct even when no camera SDP exists — which is the state this
|
|
2592
|
+
* whole path is for. `extractAudioMediaBlock` cannot serve here: it remaps a
|
|
2593
|
+
* dynamic payload type to 97 for the ffmpeg egress graft, and a remap is
|
|
2594
|
+
* exactly what must not happen to a track the camera will later fill itself.
|
|
2595
|
+
*/
|
|
2596
|
+
function g711AudioMediaBlock(track, trackId) {
|
|
2597
|
+
return [
|
|
2598
|
+
`m=audio 0 RTP/AVP ${track.payloadType}`,
|
|
2599
|
+
"c=IN IP4 0.0.0.0",
|
|
2600
|
+
`a=rtpmap:${track.payloadType} ${track.codec}/${track.clockRateHz}`,
|
|
2601
|
+
`a=control:trackID=${trackId}`
|
|
2602
|
+
].join("\r\n");
|
|
2603
|
+
}
|
|
2604
|
+
/**
|
|
2605
|
+
* One complete RTP packet of silence: 12-byte header plus one frame of the
|
|
2606
|
+
* codec's zero byte. Pure — the caller owns the sequence, the clock and the
|
|
2607
|
+
* decision to send.
|
|
2608
|
+
*/
|
|
2609
|
+
function buildG711SilenceRtp(input) {
|
|
2610
|
+
const packet = Buffer.alloc(172, SILENCE_BYTE[input.track.codec]);
|
|
2611
|
+
packet[0] = 128;
|
|
2612
|
+
packet[1] = (input.marker ? 128 : 0) | input.track.payloadType & 127;
|
|
2613
|
+
packet.writeUInt16BE(input.sequenceNumber & 65535, 2);
|
|
2614
|
+
packet.writeUInt32BE(input.rtpTimestamp >>> 0, 4);
|
|
2615
|
+
packet.writeUInt32BE(input.ssrc >>> 0, 8);
|
|
2616
|
+
return packet;
|
|
2617
|
+
}
|
|
2618
|
+
//#endregion
|
|
2619
|
+
//#region src/stream-broker/rtsp/audio/sentry-audio-silence.ts
|
|
2620
|
+
/**
|
|
2621
|
+
* How long the camera must be quiet before the filler takes the track back.
|
|
2622
|
+
*
|
|
2623
|
+
* Longer than any plausible inter-packet gap on a live G.711 stream (20 ms
|
|
2624
|
+
* nominal, 50 pps) by two orders of magnitude, so a momentary stutter in real
|
|
2625
|
+
* audio can never be papered over with generated frames; short enough that a
|
|
2626
|
+
* consumer's decoder does not see a hole when the camera goes back to sleep.
|
|
2627
|
+
*/
|
|
2628
|
+
var DEFAULT_RESUME_GAP_MS = 1e3;
|
|
2629
|
+
var SentryAudioSilence = class {
|
|
2630
|
+
options;
|
|
2631
|
+
frameMs;
|
|
2632
|
+
resumeGapMs;
|
|
2633
|
+
tags;
|
|
2634
|
+
ssrc;
|
|
2635
|
+
timer;
|
|
2636
|
+
resumeTimer;
|
|
2637
|
+
sequenceNumber;
|
|
2638
|
+
rtpTimestamp = 0;
|
|
2639
|
+
markNext = true;
|
|
2640
|
+
emitted = 0;
|
|
2641
|
+
/** Frames that reached at least one session. See {@link stopRun}. */
|
|
2642
|
+
delivered = 0;
|
|
2643
|
+
lastRealAudioAt = 0;
|
|
2644
|
+
wanted = false;
|
|
2645
|
+
constructor(options) {
|
|
2646
|
+
this.options = options;
|
|
2647
|
+
this.frameMs = options.frameMs ?? 20;
|
|
2648
|
+
this.resumeGapMs = options.resumeGapMs ?? DEFAULT_RESUME_GAP_MS;
|
|
2649
|
+
this.tags = options.deviceId === void 0 ? {} : { tags: { deviceId: options.deviceId } };
|
|
2650
|
+
this.ssrc = Math.random() * 4294967295 >>> 0;
|
|
2651
|
+
this.sequenceNumber = Math.random() * 65535 & 65535;
|
|
2652
|
+
}
|
|
2653
|
+
/** The audio shape this filler was built for. */
|
|
2654
|
+
get track() {
|
|
2655
|
+
return this.options.track;
|
|
2656
|
+
}
|
|
2657
|
+
/** True while packets are actually being produced. */
|
|
2658
|
+
get running() {
|
|
2659
|
+
return this.timer !== void 0;
|
|
2660
|
+
}
|
|
2661
|
+
/**
|
|
2662
|
+
* Somebody wants the track filled. Idempotent — reconciliation calls this on
|
|
2663
|
+
* every session edge, and a second call while a run is in flight must not
|
|
2664
|
+
* restart the clock or re-log.
|
|
2665
|
+
*/
|
|
2666
|
+
want() {
|
|
2667
|
+
this.wanted = true;
|
|
2668
|
+
this.startRun();
|
|
2669
|
+
}
|
|
2670
|
+
/**
|
|
2671
|
+
* Nobody wants the track filled any more (the last sentry left, the operator
|
|
2672
|
+
* muted the device, the broker is going away). Idempotent.
|
|
2673
|
+
*/
|
|
2674
|
+
release(reason) {
|
|
2675
|
+
this.wanted = false;
|
|
2676
|
+
this.clearResume();
|
|
2677
|
+
this.stopRun(reason);
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* The camera's own audio just arrived.
|
|
2681
|
+
*
|
|
2682
|
+
* Synchronous, and the first thing `pushAudioRtpPassthrough` does: the timer
|
|
2683
|
+
* is gone before this packet is written to any session, so no generated frame
|
|
2684
|
+
* can follow it. Called on EVERY real packet, not only the first — the rolling
|
|
2685
|
+
* gap is what lets the filler come back when the camera sleeps again, without
|
|
2686
|
+
* a second signal anybody has to remember to send.
|
|
2687
|
+
*/
|
|
2688
|
+
noteRealAudio(nowMs) {
|
|
2689
|
+
this.lastRealAudioAt = nowMs;
|
|
2690
|
+
this.stopRun("real audio");
|
|
2691
|
+
this.armResume();
|
|
2692
|
+
}
|
|
2693
|
+
startRun() {
|
|
2694
|
+
if (this.timer !== void 0) return;
|
|
2695
|
+
if (!this.wanted) return;
|
|
2696
|
+
if (Date.now() - this.lastRealAudioAt < this.resumeGapMs) {
|
|
2697
|
+
this.armResume();
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
this.rtpTimestamp = Math.floor(Date.now() * this.options.track.clockRateHz / 1e3) >>> 0;
|
|
2701
|
+
this.markNext = true;
|
|
2702
|
+
this.emitted = 0;
|
|
2703
|
+
this.delivered = 0;
|
|
2704
|
+
this.timer = setInterval(() => this.tick(), this.frameMs);
|
|
2705
|
+
this.options.logger?.info("sentry audio: filling the track with silence", {
|
|
2706
|
+
...this.tags,
|
|
2707
|
+
meta: {
|
|
2708
|
+
codec: this.options.track.codec,
|
|
2709
|
+
payloadType: this.options.track.payloadType,
|
|
2710
|
+
frameMs: this.frameMs,
|
|
2711
|
+
clockRateHz: this.options.track.clockRateHz
|
|
2712
|
+
}
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
stopRun(reason) {
|
|
2716
|
+
if (this.timer === void 0) return;
|
|
2717
|
+
clearInterval(this.timer);
|
|
2718
|
+
this.timer = void 0;
|
|
2719
|
+
this.options.logger?.info("sentry audio: stopped filling the track", {
|
|
2720
|
+
...this.tags,
|
|
2721
|
+
meta: {
|
|
2722
|
+
reason,
|
|
2723
|
+
framesEmitted: this.emitted,
|
|
2724
|
+
framesDelivered: this.delivered
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Re-check once the gap has elapsed.
|
|
2730
|
+
*
|
|
2731
|
+
* A single pending timeout rather than a free-running ticker that decides per
|
|
2732
|
+
* frame: while the camera is awake this costs one timer per second instead of
|
|
2733
|
+
* fifty, and while it is asleep it costs nothing at all — the run is the
|
|
2734
|
+
* ticker.
|
|
2735
|
+
*/
|
|
2736
|
+
armResume() {
|
|
2737
|
+
if (!this.wanted) return;
|
|
2738
|
+
if (this.resumeTimer !== void 0) return;
|
|
2739
|
+
this.resumeTimer = setTimeout(() => {
|
|
2740
|
+
this.resumeTimer = void 0;
|
|
2741
|
+
this.startRun();
|
|
2742
|
+
}, this.resumeGapMs);
|
|
2743
|
+
this.resumeTimer.unref?.();
|
|
2744
|
+
}
|
|
2745
|
+
clearResume() {
|
|
2746
|
+
if (this.resumeTimer === void 0) return;
|
|
2747
|
+
clearTimeout(this.resumeTimer);
|
|
2748
|
+
this.resumeTimer = void 0;
|
|
2749
|
+
}
|
|
2750
|
+
tick() {
|
|
2751
|
+
const rtp = buildG711SilenceRtp({
|
|
2752
|
+
track: this.options.track,
|
|
2753
|
+
ssrc: this.ssrc,
|
|
2754
|
+
sequenceNumber: this.sequenceNumber,
|
|
2755
|
+
rtpTimestamp: this.rtpTimestamp,
|
|
2756
|
+
marker: this.markNext
|
|
2757
|
+
});
|
|
2758
|
+
this.sequenceNumber = this.sequenceNumber + 1 & 65535;
|
|
2759
|
+
this.rtpTimestamp = this.rtpTimestamp + 160 >>> 0;
|
|
2760
|
+
this.markNext = false;
|
|
2761
|
+
this.emitted += 1;
|
|
2762
|
+
if (this.options.emit(rtp) > 0) this.delivered += 1;
|
|
2763
|
+
}
|
|
2764
|
+
};
|
|
2553
2765
|
/**
|
|
2554
2766
|
* Extract the `m=audio` media block from an ffmpeg-generated SDP, normalised
|
|
2555
2767
|
* for grafting (port 0, non-colliding payload type, own `a=control`, explicit
|
|
@@ -2597,7 +2809,7 @@ function appendAudioMediaBlock(videoSdp, audioBlock) {
|
|
|
2597
2809
|
* the result onto its counters and its `continue`.
|
|
2598
2810
|
*/
|
|
2599
2811
|
function admitJoinBurst(input) {
|
|
2600
|
-
if (input.
|
|
2812
|
+
if (input.liveEdgeOnly) return !input.fromRing || input.spanMs > input.liveEdgeMaxSpanMs ? "live-edge" : "none";
|
|
2601
2813
|
if (!input.detectionIntent && input.spanMs > input.bound.maxSpanMs) return "span";
|
|
2602
2814
|
if (input.packets > input.bound.maxPackets) return "packets";
|
|
2603
2815
|
return "none";
|
|
@@ -2799,6 +3011,14 @@ var LIVE_EDGE_MAX_SPAN_MS = 500;
|
|
|
2799
3011
|
*/
|
|
2800
3012
|
var PRIME_MAX_SPAN_MS = 2e3;
|
|
2801
3013
|
/**
|
|
3014
|
+
* The `a=control:trackID=` the sentry's grafted audio block is given.
|
|
3015
|
+
*
|
|
3016
|
+
* The same id the transcode egress grafts its own audio onto
|
|
3017
|
+
* (`extractAudioMediaBlock`'s default) and the same one `sendAudioRtp` writes
|
|
3018
|
+
* to (`tracks[1]`), because the video track is always trackID=0.
|
|
3019
|
+
*/
|
|
3020
|
+
var SENTRY_AUDIO_TRACK_ID = 1;
|
|
3021
|
+
/**
|
|
2802
3022
|
* Defensive packet ceiling on a join burst, independent of {@link
|
|
2803
3023
|
* PRIME_MAX_SPAN_MS}. The ring is trimmed to one GOP by RTP timestamp, so a
|
|
2804
3024
|
* burst this large means the timestamps stopped describing the stream (wrap,
|
|
@@ -2891,6 +3111,27 @@ var RtspRestreamer = class {
|
|
|
2891
3111
|
* a broker going idle overnight would wake up distributing audio again.
|
|
2892
3112
|
*/
|
|
2893
3113
|
deviceAudioMuted = false;
|
|
3114
|
+
/**
|
|
3115
|
+
* The camera's audio shape, remembered so a SENTRY can be handed a track to
|
|
3116
|
+
* fill while the camera is asleep.
|
|
3117
|
+
*
|
|
3118
|
+
* NOT cleared by {@link resetStreamState}, for exactly the reason
|
|
3119
|
+
* {@link deviceAudioMuted} is not: that method resets what belongs to one
|
|
3120
|
+
* CONNECTION, and this describes the CAMERA. A sleeping camera has no SDP to
|
|
3121
|
+
* read — it is the whole premise of the sentry — so if this did not survive
|
|
3122
|
+
* the suspend there would never be an audio track to announce at the moment
|
|
3123
|
+
* one is needed.
|
|
3124
|
+
*
|
|
3125
|
+
* `null` means nothing has ever described this camera's audio to us (a cold
|
|
3126
|
+
* hub whose battery camera has not been awake since boot), or its codec is
|
|
3127
|
+
* one whose silence cannot be generated exactly. Both are logged; neither is
|
|
3128
|
+
* guessed at.
|
|
3129
|
+
*/
|
|
3130
|
+
sentryAudioTrack = null;
|
|
3131
|
+
/** Fills a sentry's audio track while the camera sleeps. Built on demand. */
|
|
3132
|
+
sentryAudioSilence = null;
|
|
3133
|
+
/** One-shot guard for "a sentry attached and there is no track to fill". */
|
|
3134
|
+
sentryAudioGapLogged = false;
|
|
2894
3135
|
/** Cached last keyframe NALs (SPS/PPS + IDR) for late-joining clients. */
|
|
2895
3136
|
lastKeyframeNals = [];
|
|
2896
3137
|
lastKeyframeNalBytes = 0;
|
|
@@ -3099,6 +3340,156 @@ var RtspRestreamer = class {
|
|
|
3099
3340
|
return this.mutedSdp ?? this.sdp;
|
|
3100
3341
|
}
|
|
3101
3342
|
/**
|
|
3343
|
+
* The SDP a SENTRY dial is answered with: the picture it is being served,
|
|
3344
|
+
* plus the audio track that picture will be accompanied by.
|
|
3345
|
+
*
|
|
3346
|
+
* Three cases, in this order and no other:
|
|
3347
|
+
*
|
|
3348
|
+
* 1. The operator muted this camera broker-side (D83). That mute IS a device
|
|
3349
|
+
* policy and outranks everything here — the sentry is answered video-only,
|
|
3350
|
+
* exactly as every other consumer is.
|
|
3351
|
+
* 2. The current connection already describes audio (the camera is up and we
|
|
3352
|
+
* are serving its own SDP). It is served verbatim: grafting on top would
|
|
3353
|
+
* announce the track twice, and the camera's own description is by
|
|
3354
|
+
* definition the one its RTP will match.
|
|
3355
|
+
* 3. Otherwise — the sleeping case, where the SDP is built from the
|
|
3356
|
+
* placeholder frame and has no audio at all — the retained shape is
|
|
3357
|
+
* grafted on as trackID=1.
|
|
3358
|
+
*
|
|
3359
|
+
* Without a retained shape this degrades to the video-only SDP, which is what
|
|
3360
|
+
* the whole path used to serve unconditionally. That is a REFUSAL, reported
|
|
3361
|
+
* by {@link reconcileSentryAudio}: a sentry with no audio is a sentry that
|
|
3362
|
+
* will never have any, and the operator has to be able to see it rather than
|
|
3363
|
+
* infer it from a silent stream.
|
|
3364
|
+
*/
|
|
3365
|
+
getSentrySdp() {
|
|
3366
|
+
if (this.deviceAudioMuted) return this.getMutedSdp();
|
|
3367
|
+
const full = this.sdp;
|
|
3368
|
+
if (full !== null && /^m=audio/m.test(full)) return full;
|
|
3369
|
+
const videoOnly = this.getMutedSdp();
|
|
3370
|
+
const track = this.sentryAudioTrack;
|
|
3371
|
+
if (track === null || videoOnly === null) return videoOnly;
|
|
3372
|
+
return appendAudioMediaBlock(videoOnly, g711AudioMediaBlock(track, SENTRY_AUDIO_TRACK_ID));
|
|
3373
|
+
}
|
|
3374
|
+
/**
|
|
3375
|
+
* The SDP for a parsed dial's variant.
|
|
3376
|
+
*
|
|
3377
|
+
* One switch and not three call sites: the listen server reads the SDP twice
|
|
3378
|
+
* (once to wait for it, once to answer with it), and those two reads
|
|
3379
|
+
* disagreeing about which variant a dial gets is a class of bug that costs a
|
|
3380
|
+
* consumer an empty DESCRIBE.
|
|
3381
|
+
*/
|
|
3382
|
+
getSdpFor(variant) {
|
|
3383
|
+
if (variant === "sentry") return this.getSentrySdp();
|
|
3384
|
+
if (variant === "muted") return this.getMutedSdp();
|
|
3385
|
+
return this.getSdp();
|
|
3386
|
+
}
|
|
3387
|
+
/**
|
|
3388
|
+
* Remember (or forget) the camera's audio shape for the sentry.
|
|
3389
|
+
*
|
|
3390
|
+
* Called from the broker's `onAudioTrack`, which is the only place the
|
|
3391
|
+
* camera's own SDP is parsed. `null` forgets it — a camera whose audio was
|
|
3392
|
+
* turned off, or whose codec this system cannot fill exactly.
|
|
3393
|
+
*/
|
|
3394
|
+
retainSentryAudioTrack(track) {
|
|
3395
|
+
const prior = this.sentryAudioTrack;
|
|
3396
|
+
if (prior?.codec === track?.codec && prior?.payloadType === track?.payloadType) return;
|
|
3397
|
+
this.sentryAudioTrack = track;
|
|
3398
|
+
this.sentryAudioSilence?.release("audio shape changed");
|
|
3399
|
+
this.sentryAudioSilence = null;
|
|
3400
|
+
this.sentryAudioGapLogged = false;
|
|
3401
|
+
this.logger?.info("sentry audio: track shape retained", {
|
|
3402
|
+
tags: { deviceId: this.deviceId },
|
|
3403
|
+
meta: {
|
|
3404
|
+
streamName: this.streamName,
|
|
3405
|
+
codec: track?.codec ?? null
|
|
3406
|
+
}
|
|
3407
|
+
});
|
|
3408
|
+
this.reconcileSentryAudio();
|
|
3409
|
+
}
|
|
3410
|
+
/** The retained audio shape, or `null`. Survives a suspend by design. */
|
|
3411
|
+
getSentryAudioTrack() {
|
|
3412
|
+
return this.sentryAudioTrack;
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* Whether generated silence is being produced for this stream right now.
|
|
3416
|
+
*
|
|
3417
|
+
* Exposed because "is the filler still running" is not answerable from the
|
|
3418
|
+
* wire: a run whose last sentry has left writes to nobody, so a socket that
|
|
3419
|
+
* sees nothing looks exactly like a timer that was released and exactly like
|
|
3420
|
+
* one that was leaked. That ambiguity survived a deliberate sabotage of
|
|
3421
|
+
* {@link removeSession}'s reconcile with the whole suite green.
|
|
3422
|
+
*/
|
|
3423
|
+
isSentryAudioFilling() {
|
|
3424
|
+
return this.sentryAudioSilence?.running === true;
|
|
3425
|
+
}
|
|
3426
|
+
/**
|
|
3427
|
+
* Start or stop filling the sentry audio track to match who is attached.
|
|
3428
|
+
*
|
|
3429
|
+
* Called on every edge that can change the answer — a session arriving or
|
|
3430
|
+
* leaving, the operator's mute, a new audio shape — and never on a per-packet
|
|
3431
|
+
* path. Idempotent in both directions.
|
|
3432
|
+
*/
|
|
3433
|
+
reconcileSentryAudio() {
|
|
3434
|
+
if (!(this.getPrestreamSessionCount() > 0 && !this.deviceAudioMuted)) {
|
|
3435
|
+
this.sentryAudioSilence?.release("no sentry session");
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
const track = this.sentryAudioTrack;
|
|
3439
|
+
if (track === null) {
|
|
3440
|
+
if (!this.sentryAudioGapLogged) {
|
|
3441
|
+
this.sentryAudioGapLogged = true;
|
|
3442
|
+
this.logger?.info("sentry audio: no track to fill — this dial gets no audio", {
|
|
3443
|
+
tags: { deviceId: this.deviceId },
|
|
3444
|
+
meta: {
|
|
3445
|
+
streamName: this.streamName,
|
|
3446
|
+
reason: "no retained g711 audio shape"
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
}
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
if (this.sentryAudioSilence === null) this.sentryAudioSilence = new SentryAudioSilence({
|
|
3453
|
+
track,
|
|
3454
|
+
emit: (rtp) => this.sendSilenceToSentrySessions(rtp),
|
|
3455
|
+
...this.logger ? { logger: this.logger } : {},
|
|
3456
|
+
...this.deviceId === void 0 ? {} : { deviceId: this.deviceId }
|
|
3457
|
+
});
|
|
3458
|
+
this.sentryAudioSilence.want();
|
|
3459
|
+
}
|
|
3460
|
+
/**
|
|
3461
|
+
* Hand one generated packet to every sentry session that can take it.
|
|
3462
|
+
*
|
|
3463
|
+
* Gated on PLAY, on the sentry intent and on the session's own mute — and
|
|
3464
|
+
* deliberately NOT on `pendingKeyframe`, which is the one gate the real audio
|
|
3465
|
+
* path applies and this one must not.
|
|
3466
|
+
*
|
|
3467
|
+
* That gate exists to keep live audio behind a video join REPLAY, so a
|
|
3468
|
+
* session's two tracks are not handed media from two different moments.
|
|
3469
|
+
* Generated silence belongs to no replay and to no GOP. Worse, waiting for
|
|
3470
|
+
* the video would reproduce the defect: the placeholder picture arrives at
|
|
3471
|
+
* 1 fps at best and not at all when the sentry refuses (unknown geometry, no
|
|
3472
|
+
* cached snapshot), so the track a consumer's ffmpeg is waiting on would stay
|
|
3473
|
+
* empty for exactly as long as the picture did — which is the stall the
|
|
3474
|
+
* announced-but-unfilled `m=audio` was feared for in the first place.
|
|
3475
|
+
*
|
|
3476
|
+
* Returns how many took it, so a run that delivers to nobody is visible.
|
|
3477
|
+
*/
|
|
3478
|
+
sendSilenceToSentrySessions(rtp) {
|
|
3479
|
+
const packet = {
|
|
3480
|
+
data: rtp,
|
|
3481
|
+
marker: (rtp[1] & 128) !== 0
|
|
3482
|
+
};
|
|
3483
|
+
let served = 0;
|
|
3484
|
+
for (const session of this.sessions.values()) {
|
|
3485
|
+
if (!session.isPrestreamIntent()) continue;
|
|
3486
|
+
if (session.isMuted()) continue;
|
|
3487
|
+
if (!session.isPlaying()) continue;
|
|
3488
|
+
if (session.sendAudioRtp(packet) === "written") served += 1;
|
|
3489
|
+
}
|
|
3490
|
+
return served;
|
|
3491
|
+
}
|
|
3492
|
+
/**
|
|
3102
3493
|
* Apply the broker-side audio mute. Returns how many audio-bearing pull
|
|
3103
3494
|
* sessions were closed so they re-`DESCRIBE` against the new SDP.
|
|
3104
3495
|
*
|
|
@@ -3110,6 +3501,7 @@ var RtspRestreamer = class {
|
|
|
3110
3501
|
setAudioMuted(muted) {
|
|
3111
3502
|
if (this.deviceAudioMuted === muted) return 0;
|
|
3112
3503
|
this.deviceAudioMuted = muted;
|
|
3504
|
+
this.reconcileSentryAudio();
|
|
3113
3505
|
const audioBearing = [...this.sessions.values()].filter((s) => !s.isMuted());
|
|
3114
3506
|
for (const session of audioBearing) this.killSession(session.getSessionId());
|
|
3115
3507
|
if (audioBearing.length > 0) this.logger?.info("audio mute changed — closing audio-bearing RTSP pulls so they re-DESCRIBE", {
|
|
@@ -3161,6 +3553,7 @@ var RtspRestreamer = class {
|
|
|
3161
3553
|
total: this.sessions.size
|
|
3162
3554
|
} });
|
|
3163
3555
|
this._onSessionCountChanged?.();
|
|
3556
|
+
this.reconcileSentryAudio();
|
|
3164
3557
|
if (this._rtpPassthroughMode) setTimeout(() => this.servePendingSessionsRtp(), 0);
|
|
3165
3558
|
else if (this.lastKeyframeNals.length > 0 && this.packetizer) setTimeout(() => this.servePendingSessions(), 0);
|
|
3166
3559
|
}
|
|
@@ -3336,7 +3729,7 @@ var RtspRestreamer = class {
|
|
|
3336
3729
|
}
|
|
3337
3730
|
}
|
|
3338
3731
|
const withhold = admitJoinBurst({
|
|
3339
|
-
|
|
3732
|
+
liveEdgeOnly: session.isLiveEdgeOnly(),
|
|
3340
3733
|
detectionIntent,
|
|
3341
3734
|
fromRing,
|
|
3342
3735
|
spanMs,
|
|
@@ -3599,6 +3992,7 @@ var RtspRestreamer = class {
|
|
|
3599
3992
|
*/
|
|
3600
3993
|
pushAudioRtpPassthrough(rtpData) {
|
|
3601
3994
|
if (this.deviceAudioMuted) return;
|
|
3995
|
+
this.sentryAudioSilence?.noteRealAudio(Date.now());
|
|
3602
3996
|
if (this.sessions.size === 0) return;
|
|
3603
3997
|
const rtpPacket = {
|
|
3604
3998
|
data: rtpData,
|
|
@@ -3619,6 +4013,7 @@ var RtspRestreamer = class {
|
|
|
3619
4013
|
total: this.sessions.size
|
|
3620
4014
|
} });
|
|
3621
4015
|
this._onSessionCountChanged?.();
|
|
4016
|
+
this.reconcileSentryAudio();
|
|
3622
4017
|
}
|
|
3623
4018
|
/**
|
|
3624
4019
|
* Force-close + remove a session by id. Used by the admin `killClient`
|
|
@@ -3647,6 +4042,7 @@ var RtspRestreamer = class {
|
|
|
3647
4042
|
}
|
|
3648
4043
|
destroy() {
|
|
3649
4044
|
this._onSessionCountChanged = null;
|
|
4045
|
+
this.sentryAudioSilence?.release("restreamer destroyed");
|
|
3650
4046
|
this.abortAllReplays();
|
|
3651
4047
|
this.deframer?.flush();
|
|
3652
4048
|
for (const session of this.sessions.values()) session.close();
|
|
@@ -3816,9 +4212,11 @@ var RtspSession = class {
|
|
|
3816
4212
|
* {@link NO_DECLARATION} when it declared nothing — which is every consumer
|
|
3817
4213
|
* except the per-session detection decode and the recorder's writer.
|
|
3818
4214
|
*
|
|
3819
|
-
* Kept separate from {@link muted} deliberately:
|
|
3820
|
-
*
|
|
3821
|
-
*
|
|
4215
|
+
* Kept separate from {@link muted} deliberately: the two say different things
|
|
4216
|
+
* and, since D525, they no longer coincide — a `/sentry` dial declares an
|
|
4217
|
+
* intent and is NOT muted, while the join rule that used to read `isMuted()`
|
|
4218
|
+
* now reads {@link isLiveEdgeOnly} and keeps every older population's
|
|
4219
|
+
* behaviour byte-for-byte.
|
|
3822
4220
|
*
|
|
3823
4221
|
* One field and not two, because the purpose and the amount are one
|
|
3824
4222
|
* statement (D382): a session that names a purpose without naming an amount
|
|
@@ -3880,6 +4278,20 @@ var RtspSession = class {
|
|
|
3880
4278
|
return this.muted;
|
|
3881
4279
|
}
|
|
3882
4280
|
/**
|
|
4281
|
+
* Whether this session is a RE-CLOCKING leg that may only join at the live
|
|
4282
|
+
* edge — the second question `isMuted()` used to answer.
|
|
4283
|
+
*
|
|
4284
|
+
* Derived from the one authority (`isLiveEdgeOnlyDial`) rather than stored,
|
|
4285
|
+
* so the rule cannot drift between the parser and the session. Unchanged for
|
|
4286
|
+
* every population that existed before the sentry gained audio.
|
|
4287
|
+
*/
|
|
4288
|
+
isLiveEdgeOnly() {
|
|
4289
|
+
return require_restream_intent.isLiveEdgeOnlyDial({
|
|
4290
|
+
muted: this.muted,
|
|
4291
|
+
declaration: this.declaration
|
|
4292
|
+
});
|
|
4293
|
+
}
|
|
4294
|
+
/**
|
|
3883
4295
|
* The `User-Agent` this client put on its first request, or `null`.
|
|
3884
4296
|
*
|
|
3885
4297
|
* The ONLY identity that crosses from a consumer process into this session.
|
|
@@ -4329,7 +4741,9 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4329
4741
|
async dispatch(socket, requestBuffer) {
|
|
4330
4742
|
const uriMatch = (requestBuffer.split("\r\n")[0] ?? "").match(/\s(rtsp:\/\/[^\s]+)\s/);
|
|
4331
4743
|
const pathMatch = (uriMatch ? uriMatch[1] : "").match(/rtsp:\/\/[^/]+(\/.*?)(?:\s|$)/);
|
|
4332
|
-
const
|
|
4744
|
+
const parsed = require_restream_intent.parseRestreamPath(pathMatch ? pathMatch[1].replace(/^\//, "").replace(/\/trackID=\d+$/, "") : "");
|
|
4745
|
+
const { lookupPath, muted: isMuted, declaration } = parsed;
|
|
4746
|
+
const sdpVariant = require_restream_intent.sdpVariantFor(parsed);
|
|
4333
4747
|
const restreamer = this.restreamers.get(lookupPath);
|
|
4334
4748
|
if (!restreamer) {
|
|
4335
4749
|
socket.write(`RTSP/1.0 404 Not Found\r\nCSeq: 1\r\n\r\n`);
|
|
@@ -4356,8 +4770,8 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4356
4770
|
};
|
|
4357
4771
|
socket.once("close", releaseOnce);
|
|
4358
4772
|
socket.once("error", releaseOnce);
|
|
4359
|
-
await this.waitForSdp(restreamer,
|
|
4360
|
-
const session = new RtspSession(socket,
|
|
4773
|
+
await this.waitForSdp(restreamer, sdpVariant, 3e3);
|
|
4774
|
+
const session = new RtspSession(socket, restreamer.getSdpFor(sdpVariant) ?? restreamer.getSdp() ?? "", () => {
|
|
4361
4775
|
restreamer.removeSession(session.getSessionId());
|
|
4362
4776
|
releaseOnce();
|
|
4363
4777
|
}, isMuted, declaration);
|
|
@@ -4372,10 +4786,10 @@ var RtspListenServer = class RtspListenServer {
|
|
|
4372
4786
|
* a cold broker that's typically ~200-1000 ms. Resolves `true` if the
|
|
4373
4787
|
* SDP becomes non-empty within the budget, `false` on timeout.
|
|
4374
4788
|
*/
|
|
4375
|
-
async waitForSdp(restreamer,
|
|
4789
|
+
async waitForSdp(restreamer, variant, timeoutMs) {
|
|
4376
4790
|
const start = Date.now();
|
|
4377
4791
|
const have = () => {
|
|
4378
|
-
const s =
|
|
4792
|
+
const s = restreamer.getSdpFor(variant) ?? restreamer.getSdp();
|
|
4379
4793
|
return !!s && s.length > 0;
|
|
4380
4794
|
};
|
|
4381
4795
|
if (have()) return true;
|
|
@@ -15022,6 +15436,12 @@ var StreamBroker = class StreamBroker {
|
|
|
15022
15436
|
supportedNative
|
|
15023
15437
|
}
|
|
15024
15438
|
});
|
|
15439
|
+
const sentryTrack = g711TrackFor(codecUpper);
|
|
15440
|
+
this.rtspRestreamer.retainSentryAudioTrack(sentryTrack);
|
|
15441
|
+
if (sentryTrack === null) this.logger?.info("sentry audio: codec cannot be filled — a sentry gets no audio", {
|
|
15442
|
+
tags: { deviceId: this.numericDeviceId },
|
|
15443
|
+
meta: { codec: codecUpper }
|
|
15444
|
+
});
|
|
15025
15445
|
let supported = supportedNative;
|
|
15026
15446
|
let effectiveSampleRate = audioTrack.clockRate;
|
|
15027
15447
|
let effectiveChannels = audioTrack.channels;
|
|
@@ -15870,6 +16290,7 @@ var TranscodePipelineManager = class {
|
|
|
15870
16290
|
resolveStreamHwAccel;
|
|
15871
16291
|
spawnFn;
|
|
15872
16292
|
acquireChannel;
|
|
16293
|
+
sentryOffered;
|
|
15873
16294
|
constructor(opts) {
|
|
15874
16295
|
this.logger = opts.logger;
|
|
15875
16296
|
this.hwaccel = opts.hwaccel ?? null;
|
|
@@ -15881,6 +16302,7 @@ var TranscodePipelineManager = class {
|
|
|
15881
16302
|
this.resolveStreamHwAccel = opts.resolveStreamHwAccel ?? null;
|
|
15882
16303
|
this.spawnFn = opts.spawnFn ?? node_child_process.spawn;
|
|
15883
16304
|
this.acquireChannel = opts.acquireChannel ?? null;
|
|
16305
|
+
this.sentryOffered = opts.sentryOffered ?? null;
|
|
15884
16306
|
}
|
|
15885
16307
|
async acquire(input) {
|
|
15886
16308
|
const wantedAudio = input.audio ?? "aac";
|
|
@@ -15906,8 +16328,9 @@ var TranscodePipelineManager = class {
|
|
|
15906
16328
|
const passVideoCodec = sourceCodec ?? "H264";
|
|
15907
16329
|
const url = this.resolveSourceUrl(input.deviceId, sourceStream.camStreamId, wantedAudio === "none");
|
|
15908
16330
|
const key = pipelineKeyFor(input.deviceId, sourceStream.camStreamId, "copy", wantedAudio, outResolution, outputArgs, false);
|
|
15909
|
-
return this.
|
|
16331
|
+
return await this.shareOrCreatePassthroughAsync(key, async () => ({
|
|
15910
16332
|
url,
|
|
16333
|
+
sentryUrl: await this.resolveSentryUrl(input.deviceId, url),
|
|
15911
16334
|
videoCodec: passVideoCodec,
|
|
15912
16335
|
audioCodec: "passthrough",
|
|
15913
16336
|
resolution: outResolution,
|
|
@@ -15977,6 +16400,7 @@ var TranscodePipelineManager = class {
|
|
|
15977
16400
|
const url = await egress.start();
|
|
15978
16401
|
const source = {
|
|
15979
16402
|
url,
|
|
16403
|
+
sentryUrl: null,
|
|
15980
16404
|
videoCodec: opts.targetCodec,
|
|
15981
16405
|
audioCodec: opts.audio,
|
|
15982
16406
|
resolution: opts.outResolution,
|
|
@@ -16152,13 +16576,44 @@ var TranscodePipelineManager = class {
|
|
|
16152
16576
|
}
|
|
16153
16577
|
return entry;
|
|
16154
16578
|
}
|
|
16155
|
-
|
|
16579
|
+
/**
|
|
16580
|
+
* The SENTRY counterpart of a passthrough url, or `null`.
|
|
16581
|
+
*
|
|
16582
|
+
* `sentryEligible` is asked through the injected lookup — the broker manager
|
|
16583
|
+
* owns those facts — and an unwired lookup is a NO. A refusal is silent on
|
|
16584
|
+
* purpose: "this mains camera has no sentry" is the answer for most of the
|
|
16585
|
+
* fleet and logging it per acquire would be noise. A FAILED ask is not: it is
|
|
16586
|
+
* a question that could not be answered, and it is reported as such before
|
|
16587
|
+
* being resolved to `null`.
|
|
16588
|
+
*/
|
|
16589
|
+
async resolveSentryUrl(deviceId, url) {
|
|
16590
|
+
if (this.sentryOffered === null) return null;
|
|
16591
|
+
try {
|
|
16592
|
+
if (!await this.sentryOffered(deviceId)) return null;
|
|
16593
|
+
} catch (error) {
|
|
16594
|
+
this.logger.warn("getStreamWithCodec: could not decide whether a sentry dial is offered", {
|
|
16595
|
+
tags: { deviceId },
|
|
16596
|
+
meta: { error: require_dist.errMsg(error) }
|
|
16597
|
+
});
|
|
16598
|
+
return null;
|
|
16599
|
+
}
|
|
16600
|
+
return require_restream_intent.withSentryIntent(url);
|
|
16601
|
+
}
|
|
16602
|
+
async shareOrCreatePassthroughAsync(key, factory) {
|
|
16603
|
+
const existing = this.entries.get(key);
|
|
16604
|
+
if (existing) {
|
|
16605
|
+
this.shareEntry(existing);
|
|
16606
|
+
return existing.source;
|
|
16607
|
+
}
|
|
16608
|
+
return this.shareOrCreatePassthrough(key, await factory());
|
|
16609
|
+
}
|
|
16610
|
+
shareOrCreatePassthrough(key, made) {
|
|
16156
16611
|
const existing = this.entries.get(key);
|
|
16157
16612
|
if (existing) {
|
|
16158
16613
|
this.shareEntry(existing);
|
|
16159
16614
|
return existing.source;
|
|
16160
16615
|
}
|
|
16161
|
-
const source =
|
|
16616
|
+
const source = made;
|
|
16162
16617
|
const entry = {
|
|
16163
16618
|
key,
|
|
16164
16619
|
source,
|
|
@@ -16825,6 +17280,10 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16825
17280
|
resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
|
|
16826
17281
|
ffmpegConfig: () => this.ffmpegConfig,
|
|
16827
17282
|
resolveStreamHwAccel: (deviceId, camStreamId) => this.getStreamHwAccelOverride(deviceId, camStreamId),
|
|
17283
|
+
sentryOffered: async (deviceId) => sentryEligible({
|
|
17284
|
+
batteryOperated: await this.isBatteryOperated(deviceId),
|
|
17285
|
+
declaresSignals: (await this.declaredSignalCodes(deviceId)).length > 0
|
|
17286
|
+
}).eligible,
|
|
16828
17287
|
acquireChannel: (brokerId) => {
|
|
16829
17288
|
const restreamer = new RtspRestreamer(brokerId);
|
|
16830
17289
|
const token = this.rtspServer.registerRestreamer(brokerId, restreamer);
|