@camstack/addon-pipeline 1.2.44 → 1.2.45
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 +3 -2
- package/dist/detection-pipeline/index.mjs +3 -2
- package/dist/{dist-DOu93i_g.mjs → dist-BDkEhx4Z.mjs} +692 -123
- package/dist/{dist-BuFE5rOK.js → dist-QyZ7QSRP.js} +697 -122
- package/dist/{event-loop-stall-monitor-L08yG6bB.js → event-loop-stall-monitor-COEKVBQK.js} +14 -7
- package/dist/{event-loop-stall-monitor-CShgQE6l.mjs → event-loop-stall-monitor-IAuNEdmG.mjs} +14 -7
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/session-decode/decode-worker-child.js +1 -1
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DiXoZnou.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C68YfSUH.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CT2bGWqM.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-B_hakWAb.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B3GxBnXM.mjs} +1 -1
- package/dist/stream-broker/{hostInit-khRXMnjl.mjs → hostInit-BMCrULpc.mjs} +2 -2
- package/dist/stream-broker/index.js +735 -424
- package/dist/stream-broker/index.mjs +731 -421
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-BOXlUhWO.mjs → worker-protocol-DFH_z2-q.mjs} +1 -1
- package/dist/{worker-protocol-VURr0nUh.js → worker-protocol-le27Mw_8.js} +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js → MaskShapeCanvas-DI4BY7W2-C7bsz4cP.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-jFDOzPCD.js → MotionZonesSettings-NcxxQN8r-BmhLinUd.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js → PrivacyMaskSettings-APgPLF7p-BJjyGWFy.js} +1 -1
- package/embed-dist/assets/{index-4CtQAybX.js → index-a4CMt-Uc.js} +12 -12
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/python/postprocessors/__init__.py +2 -0
- package/python/postprocessors/rfdetr.py +161 -0
- package/python/postprocessors/test_rfdetr.py +193 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BRc-m6W-.mjs +0 -26
|
@@ -3,18 +3,20 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
|
-
const require_dist = require("../dist-
|
|
6
|
+
const require_dist = require("../dist-QyZ7QSRP.js");
|
|
7
7
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
8
8
|
const require_model_download_service_D8B_4ktF = require("../model-download-service-D8B-4ktF-7-YxE9Wx.js");
|
|
9
9
|
let node_crypto = require("node:crypto");
|
|
10
|
-
node_crypto = require_chunk.__toESM(node_crypto);
|
|
10
|
+
node_crypto = require_chunk.__toESM(node_crypto, 1);
|
|
11
11
|
let node_child_process = require("node:child_process");
|
|
12
12
|
let node_url = require("node:url");
|
|
13
13
|
let node_os = require("node:os");
|
|
14
14
|
node_os = require_chunk.__toESM(node_os);
|
|
15
15
|
let node_fs = require("node:fs");
|
|
16
|
+
require_chunk.__toESM(node_fs, 1);
|
|
16
17
|
node_fs = require_chunk.__toESM(node_fs);
|
|
17
18
|
let node_path = require("node:path");
|
|
19
|
+
require_chunk.__toESM(node_path, 1);
|
|
18
20
|
node_path = require_chunk.__toESM(node_path);
|
|
19
21
|
let node_net = require("node:net");
|
|
20
22
|
node_net = require_chunk.__toESM(node_net);
|
|
@@ -1574,6 +1576,21 @@ var RtspRestreamer = class {
|
|
|
1574
1576
|
* trackID=1. Muted sessions still get the video-only SDP.
|
|
1575
1577
|
*/
|
|
1576
1578
|
egressAudioBlock = null;
|
|
1579
|
+
/**
|
|
1580
|
+
* BROKER-SIDE AUDIO MUTE (D83) — device policy, set by `StreamBroker`.
|
|
1581
|
+
*
|
|
1582
|
+
* Distinct from a session's own `isMuted()`, which is a PULLER's choice
|
|
1583
|
+
* (the `/muted` URL a local decoder asks for). This one overrides every
|
|
1584
|
+
* puller: while true, `getSdp()` serves the video-only SDP to everyone, so
|
|
1585
|
+
* a client that connects during the mute never declares an audio stream it
|
|
1586
|
+
* will not receive.
|
|
1587
|
+
*
|
|
1588
|
+
* NOT cleared by {@link resetStreamState}. That method resets what belongs
|
|
1589
|
+
* to one CONNECTION (keyframe caches, codec params, SDP); an operator's
|
|
1590
|
+
* mute belongs to the device and must survive a suspend/resume — otherwise
|
|
1591
|
+
* a broker going idle overnight would wake up distributing audio again.
|
|
1592
|
+
*/
|
|
1593
|
+
deviceAudioMuted = false;
|
|
1577
1594
|
/** Cached last keyframe NALs (SPS/PPS + IDR) for late-joining clients. */
|
|
1578
1595
|
lastKeyframeNals = [];
|
|
1579
1596
|
lastKeyframeNalBytes = 0;
|
|
@@ -1736,11 +1753,40 @@ var RtspRestreamer = class {
|
|
|
1736
1753
|
return this.codecParams;
|
|
1737
1754
|
}
|
|
1738
1755
|
getSdp() {
|
|
1756
|
+
if (this.deviceAudioMuted) return this.getMutedSdp();
|
|
1739
1757
|
return this.sdp;
|
|
1740
1758
|
}
|
|
1741
1759
|
getMutedSdp() {
|
|
1742
1760
|
return this.mutedSdp ?? this.sdp;
|
|
1743
1761
|
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Apply the broker-side audio mute. Returns how many audio-bearing pull
|
|
1764
|
+
* sessions were closed so they re-`DESCRIBE` against the new SDP.
|
|
1765
|
+
*
|
|
1766
|
+
* Idempotent — the same value twice closes nothing. Sessions that pulled the
|
|
1767
|
+
* `/muted` URL are left alone: they never had an audio track to gain or
|
|
1768
|
+
* lose, and killing a local decoder's feed to change nothing would be a
|
|
1769
|
+
* decode gap for free.
|
|
1770
|
+
*/
|
|
1771
|
+
setAudioMuted(muted) {
|
|
1772
|
+
if (this.deviceAudioMuted === muted) return 0;
|
|
1773
|
+
this.deviceAudioMuted = muted;
|
|
1774
|
+
const audioBearing = [...this.sessions.values()].filter((s) => !s.isMuted());
|
|
1775
|
+
for (const session of audioBearing) this.killSession(session.getSessionId());
|
|
1776
|
+
if (audioBearing.length > 0) this.logger?.info("audio mute changed — closing audio-bearing RTSP pulls so they re-DESCRIBE", {
|
|
1777
|
+
tags: { deviceId: this.deviceId },
|
|
1778
|
+
meta: {
|
|
1779
|
+
streamName: this.streamName,
|
|
1780
|
+
muted,
|
|
1781
|
+
closed: audioBearing.length
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
return audioBearing.length;
|
|
1785
|
+
}
|
|
1786
|
+
/** The broker-side audio mute currently in force for this restreamer. */
|
|
1787
|
+
isDeviceAudioMuted() {
|
|
1788
|
+
return this.deviceAudioMuted;
|
|
1789
|
+
}
|
|
1744
1790
|
getSessionCount() {
|
|
1745
1791
|
return this.sessions.size;
|
|
1746
1792
|
}
|
|
@@ -2009,6 +2055,7 @@ var RtspRestreamer = class {
|
|
|
2009
2055
|
* would mean dropping ~300 ms of audio at every join.
|
|
2010
2056
|
*/
|
|
2011
2057
|
pushAudioRtpPassthrough(rtpData) {
|
|
2058
|
+
if (this.deviceAudioMuted) return;
|
|
2012
2059
|
if (this.sessions.size === 0) return;
|
|
2013
2060
|
const rtpPacket = {
|
|
2014
2061
|
data: rtpData,
|
|
@@ -3189,182 +3236,291 @@ function pushDimension(into, constraint, requested, known) {
|
|
|
3189
3236
|
reason: `source ${known} > requested ${requested}`
|
|
3190
3237
|
});
|
|
3191
3238
|
}
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3239
|
+
/**
|
|
3240
|
+
* Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
|
|
3241
|
+
* {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
|
|
3242
|
+
*
|
|
3243
|
+
* ## Why a plane and not a callback
|
|
3244
|
+
*
|
|
3245
|
+
* The operator's requirement for HKSV was explicit: the live fMP4 source built
|
|
3246
|
+
* for it must be **dual-use**, so a HomeKit-triggered recording also lands in
|
|
3247
|
+
* CamStack as an additional videoclip source alongside the recorder and the NC
|
|
3248
|
+
* clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
|
|
3249
|
+
* (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
|
|
3250
|
+
* a second ffmpeg child of the same camera. So this is the same shape the
|
|
3251
|
+
* broker's other multi-consumer surfaces already have
|
|
3252
|
+
* (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
|
|
3253
|
+
* one producer.
|
|
3254
|
+
*
|
|
3255
|
+
* **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
|
|
3256
|
+
* clip source; both are named here so the seam is not re-invented, and neither
|
|
3257
|
+
* is built.
|
|
3258
|
+
*
|
|
3259
|
+
* ## The init segment is RETAINED
|
|
3260
|
+
*
|
|
3261
|
+
* A subscriber that attaches mid-stream — the clip consumer joining an already
|
|
3262
|
+
* running HKSV session, which is the whole dual-use case — receives the
|
|
3263
|
+
* retained `ftyp`+`moov` as its first packet and then live fragments. Without
|
|
3264
|
+
* retention its fragments are undecodable and the failure looks like a codec
|
|
3265
|
+
* problem.
|
|
3266
|
+
*
|
|
3267
|
+
* ## A slow subscriber is CLOSED, never silently gapped
|
|
3268
|
+
*
|
|
3269
|
+
* `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
|
|
3270
|
+
* click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
|
|
3271
|
+
* `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
|
|
3272
|
+
* shows a clip that fails to play with nothing anywhere saying why. So a
|
|
3273
|
+
* subscription whose queue overflows is ENDED with a reason, loudly, and the
|
|
3274
|
+
* other subscriptions are untouched.
|
|
3275
|
+
*
|
|
3276
|
+
* ## The PREBUFFER (phase 3)
|
|
3277
|
+
*
|
|
3278
|
+
* HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
|
|
3279
|
+
* is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
|
|
3280
|
+
* motion edge has none. So the plane optionally retains the last few fragments
|
|
3281
|
+
* and replays them to a subscriber that asks for them.
|
|
3282
|
+
*
|
|
3283
|
+
* Three things this ring gets right, each of which is a measured fact rather
|
|
3284
|
+
* than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
|
|
3285
|
+
*
|
|
3286
|
+
* - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
|
|
3287
|
+
* fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
|
|
3288
|
+
* window. A time-only bound is a per-camera RAM figure nobody can predict.
|
|
3289
|
+
* - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
|
|
3290
|
+
* splitter deliberately never computes a fragment's duration (a second
|
|
3291
|
+
* opinion about a fact the muxer owns), and a prebuffer cares about how long
|
|
3292
|
+
* ago the bytes turned up, which is exactly what arrival time answers.
|
|
3293
|
+
* - **A replay is not backlog.** A subscriber taking N retained fragments gets
|
|
3294
|
+
* its queue capacity raised by N for them, because closing a subscriber as a
|
|
3295
|
+
* slow consumer for the prebuffer it explicitly asked for would be the
|
|
3296
|
+
* stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
|
|
3297
|
+
* ring of 4, the guaranteed one.
|
|
3298
|
+
*
|
|
3299
|
+
* ## `isLast`
|
|
3300
|
+
*
|
|
3301
|
+
* hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
|
|
3302
|
+
* `isLast` — a generator that finishes without it produces the twelve-second
|
|
3303
|
+
* timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
|
|
3304
|
+
* plane therefore computes it at DELIVERY time: a packet is last when the plane
|
|
3305
|
+
* has ended and nothing remains queued behind it. A subscription that ends
|
|
3306
|
+
* having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
|
|
3307
|
+
* the future delegate must not open an HDS stream it cannot feed.
|
|
3308
|
+
*/
|
|
3309
|
+
var DEFAULT_QUEUE_CAPACITY = 4;
|
|
3310
|
+
var Fmp4FragmentPlane = class {
|
|
3311
|
+
logger;
|
|
3312
|
+
prebuffer;
|
|
3313
|
+
now;
|
|
3314
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
3315
|
+
/** The last init unit seen, handed to every later subscriber. */
|
|
3316
|
+
retainedInit = null;
|
|
3317
|
+
ended = false;
|
|
3318
|
+
/** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
|
|
3319
|
+
ring = [];
|
|
3320
|
+
ringBytes = 0;
|
|
3321
|
+
constructor(logger, prebuffer, now = Date.now) {
|
|
3322
|
+
this.logger = logger;
|
|
3323
|
+
this.prebuffer = prebuffer;
|
|
3324
|
+
this.now = now;
|
|
3325
|
+
}
|
|
3326
|
+
get subscriberCount() {
|
|
3327
|
+
return this.subscriptions.size;
|
|
3328
|
+
}
|
|
3329
|
+
/** True once {@link end} has been called — no further units are accepted. */
|
|
3330
|
+
get isEnded() {
|
|
3331
|
+
return this.ended;
|
|
3332
|
+
}
|
|
3333
|
+
/** What the prebuffer ring holds right now. All zeroes when disabled. */
|
|
3334
|
+
prebufferStats() {
|
|
3335
|
+
const oldest = this.ring[0];
|
|
3336
|
+
return {
|
|
3337
|
+
fragments: this.ring.length,
|
|
3338
|
+
bytes: this.ringBytes,
|
|
3339
|
+
spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
subscribe(input) {
|
|
3343
|
+
const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
|
|
3344
|
+
const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
|
|
3345
|
+
const sub = {
|
|
3346
|
+
id: `fmp4-${(0, node_crypto.randomUUID)()}`,
|
|
3347
|
+
tag: input.tag,
|
|
3348
|
+
subscribedAt: this.now(),
|
|
3349
|
+
capacity: requested + replay.length,
|
|
3350
|
+
queue: [],
|
|
3351
|
+
delivered: 0,
|
|
3352
|
+
closedReason: null,
|
|
3353
|
+
wake: null,
|
|
3354
|
+
iterating: false
|
|
3355
|
+
};
|
|
3356
|
+
this.subscriptions.set(sub.id, sub);
|
|
3357
|
+
if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
|
|
3358
|
+
for (const retained of replay) this.enqueue(sub, retained.unit);
|
|
3359
|
+
if (this.ended) this.closeSubscription(sub, "ended");
|
|
3360
|
+
this.logger?.info("fmp4 plane: subscribed", { meta: {
|
|
3361
|
+
subscriptionId: sub.id,
|
|
3362
|
+
tag: sub.tag,
|
|
3363
|
+
hasRetainedInit: this.retainedInit !== null,
|
|
3364
|
+
prebufferFragments: replay.length,
|
|
3365
|
+
prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
|
|
3366
|
+
} });
|
|
3367
|
+
return this.facade(sub);
|
|
3368
|
+
}
|
|
3369
|
+
/**
|
|
3370
|
+
* Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
|
|
3371
|
+
* emits exactly one per child, and a second means the child was respawned, in
|
|
3372
|
+
* which case the old one describes a stream that no longer exists.
|
|
3237
3373
|
*/
|
|
3238
|
-
|
|
3239
|
-
if (this.
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3374
|
+
publish(unit) {
|
|
3375
|
+
if (this.ended) return;
|
|
3376
|
+
if (unit.kind === "init") {
|
|
3377
|
+
this.retainedInit = unit;
|
|
3378
|
+
this.ring.length = 0;
|
|
3379
|
+
this.ringBytes = 0;
|
|
3380
|
+
} else this.retain(unit);
|
|
3381
|
+
for (const sub of this.subscriptions.values()) {
|
|
3382
|
+
if (sub.closedReason !== null) continue;
|
|
3383
|
+
this.enqueue(sub, unit);
|
|
3248
3384
|
}
|
|
3249
|
-
const next = new Uint8Array(this.buffer.length + chunk.length);
|
|
3250
|
-
next.set(this.buffer, 0);
|
|
3251
|
-
next.set(chunk, this.buffer.length);
|
|
3252
|
-
this.buffer = next;
|
|
3253
3385
|
}
|
|
3254
|
-
/**
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
*/
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3386
|
+
/**
|
|
3387
|
+
* The producer stopped. Every subscriber drains what it holds; its final
|
|
3388
|
+
* packet carries `isLast`, and its generator then completes.
|
|
3389
|
+
*/
|
|
3390
|
+
end(reason = "producer ended") {
|
|
3391
|
+
if (this.ended) return;
|
|
3392
|
+
this.ended = true;
|
|
3393
|
+
this.logger?.info("fmp4 plane: ended", { meta: {
|
|
3394
|
+
reason,
|
|
3395
|
+
subscribers: this.subscriptions.size
|
|
3396
|
+
} });
|
|
3397
|
+
for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
|
|
3398
|
+
}
|
|
3399
|
+
listSubscribers() {
|
|
3400
|
+
return [...this.subscriptions.values()].map((s) => ({
|
|
3401
|
+
tag: s.tag,
|
|
3402
|
+
subscribedAt: s.subscribedAt,
|
|
3403
|
+
delivered: s.delivered,
|
|
3404
|
+
closedReason: s.closedReason
|
|
3405
|
+
}));
|
|
3406
|
+
}
|
|
3407
|
+
/** End and forget everything. Idempotent. */
|
|
3408
|
+
dispose() {
|
|
3409
|
+
this.end("disposed");
|
|
3410
|
+
this.subscriptions.clear();
|
|
3411
|
+
this.retainedInit = null;
|
|
3412
|
+
this.ring.length = 0;
|
|
3413
|
+
this.ringBytes = 0;
|
|
3414
|
+
}
|
|
3415
|
+
/**
|
|
3416
|
+
* Add one fragment to the ring and evict from the front until BOTH bounds
|
|
3417
|
+
* hold. Eviction is oldest-first, which is the one place in this file where
|
|
3418
|
+
* dropping is correct: the ring is context, not stream — nobody is mid-decode
|
|
3419
|
+
* on it, and a subscriber only ever receives a contiguous tail of it.
|
|
3420
|
+
*/
|
|
3421
|
+
retain(unit) {
|
|
3422
|
+
const prebuffer = this.prebuffer;
|
|
3423
|
+
if (prebuffer === void 0) return;
|
|
3424
|
+
const arrivedAt = this.now();
|
|
3425
|
+
this.ring.push({
|
|
3426
|
+
unit,
|
|
3427
|
+
arrivedAt
|
|
3428
|
+
});
|
|
3429
|
+
this.ringBytes += unit.data.length;
|
|
3430
|
+
const cutoff = arrivedAt - prebuffer.windowMs;
|
|
3431
|
+
while (this.ring.length > 0) {
|
|
3432
|
+
const oldest = this.ring[0];
|
|
3433
|
+
if (oldest === void 0) break;
|
|
3434
|
+
const tooOld = oldest.arrivedAt < cutoff;
|
|
3435
|
+
const tooBig = this.ringBytes > prebuffer.maxBytes;
|
|
3436
|
+
if (!tooOld && !tooBig || this.ring.length === 1) break;
|
|
3437
|
+
this.ring.shift();
|
|
3438
|
+
this.ringBytes -= oldest.unit.data.length;
|
|
3297
3439
|
}
|
|
3298
|
-
if (type !== "mdat") return null;
|
|
3299
|
-
const fragment = this.emit("fragment", this.unitStart, boxEnd);
|
|
3300
|
-
this.unitStart = boxEnd;
|
|
3301
|
-
this.state = "idle";
|
|
3302
|
-
return fragment;
|
|
3303
3440
|
}
|
|
3304
3441
|
/**
|
|
3305
|
-
*
|
|
3306
|
-
*
|
|
3442
|
+
* The ring as a subscriber should receive it — window applied AT SUBSCRIBE
|
|
3443
|
+
* time, not only at publish time. A camera that went quiet keeps its last
|
|
3444
|
+
* fragment in the ring indefinitely (see the never-evict-the-newest rule),
|
|
3445
|
+
* and replaying a 40-second-old fragment as "prebuffer" would put stale video
|
|
3446
|
+
* at the head of a clip iOS presents as the moment of the event.
|
|
3307
3447
|
*/
|
|
3308
|
-
|
|
3309
|
-
const
|
|
3310
|
-
if (
|
|
3311
|
-
const
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
}
|
|
3325
|
-
return {
|
|
3326
|
-
type,
|
|
3327
|
-
totalBytes: Number(large)
|
|
3328
|
-
};
|
|
3329
|
-
}
|
|
3330
|
-
if (size < BOX_HEADER_BYTES) {
|
|
3331
|
-
this.fail(`box "${type}" declares an impossible size of ${size} bytes`);
|
|
3332
|
-
return null;
|
|
3448
|
+
trimmedRing() {
|
|
3449
|
+
const prebuffer = this.prebuffer;
|
|
3450
|
+
if (prebuffer === void 0) return [];
|
|
3451
|
+
const cutoff = this.now() - prebuffer.windowMs;
|
|
3452
|
+
return this.ring.filter((r) => r.arrivedAt >= cutoff);
|
|
3453
|
+
}
|
|
3454
|
+
enqueue(sub, unit) {
|
|
3455
|
+
if (sub.queue.length >= sub.capacity) {
|
|
3456
|
+
this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
|
|
3457
|
+
subscriptionId: sub.id,
|
|
3458
|
+
tag: sub.tag,
|
|
3459
|
+
capacity: sub.capacity,
|
|
3460
|
+
delivered: sub.delivered
|
|
3461
|
+
} });
|
|
3462
|
+
this.closeSubscription(sub, "slow-consumer");
|
|
3463
|
+
return;
|
|
3333
3464
|
}
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3465
|
+
sub.queue.push({
|
|
3466
|
+
kind: unit.kind,
|
|
3467
|
+
data: unit.data,
|
|
3468
|
+
sequence: unit.sequence,
|
|
3469
|
+
isLast: false
|
|
3470
|
+
});
|
|
3471
|
+
this.wake(sub);
|
|
3472
|
+
}
|
|
3473
|
+
closeSubscription(sub, reason) {
|
|
3474
|
+
if (sub.closedReason !== null) return;
|
|
3475
|
+
sub.closedReason = reason;
|
|
3476
|
+
if (reason === "slow-consumer") sub.queue.length = 0;
|
|
3477
|
+
this.wake(sub);
|
|
3478
|
+
}
|
|
3479
|
+
wake(sub) {
|
|
3480
|
+
const resume = sub.wake;
|
|
3481
|
+
sub.wake = null;
|
|
3482
|
+
resume?.();
|
|
3338
3483
|
}
|
|
3339
|
-
|
|
3340
|
-
const
|
|
3341
|
-
this.nextSequence += 1;
|
|
3484
|
+
facade(sub) {
|
|
3485
|
+
const plane = this;
|
|
3342
3486
|
return {
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3487
|
+
id: sub.id,
|
|
3488
|
+
tag: sub.tag,
|
|
3489
|
+
get delivered() {
|
|
3490
|
+
return sub.delivered;
|
|
3491
|
+
},
|
|
3492
|
+
get closedReason() {
|
|
3493
|
+
return sub.closedReason;
|
|
3494
|
+
},
|
|
3495
|
+
packets: () => plane.iterate(sub),
|
|
3496
|
+
release: () => {
|
|
3497
|
+
plane.closeSubscription(sub, "released");
|
|
3498
|
+
plane.subscriptions.delete(sub.id);
|
|
3499
|
+
}
|
|
3346
3500
|
};
|
|
3347
3501
|
}
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3502
|
+
async *iterate(sub) {
|
|
3503
|
+
if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
|
|
3504
|
+
sub.iterating = true;
|
|
3505
|
+
for (;;) {
|
|
3506
|
+
const next = sub.queue.shift();
|
|
3507
|
+
if (next === void 0) {
|
|
3508
|
+
if (sub.closedReason !== null) return;
|
|
3509
|
+
await new Promise((resolve) => {
|
|
3510
|
+
sub.wake = resolve;
|
|
3511
|
+
});
|
|
3512
|
+
continue;
|
|
3513
|
+
}
|
|
3514
|
+
const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
|
|
3515
|
+
sub.delivered += 1;
|
|
3516
|
+
yield {
|
|
3517
|
+
...next,
|
|
3518
|
+
isLast
|
|
3519
|
+
};
|
|
3520
|
+
if (isLast) return;
|
|
3521
|
+
}
|
|
3364
3522
|
}
|
|
3365
3523
|
};
|
|
3366
|
-
//#endregion
|
|
3367
|
-
//#region src/stream-broker/stream-broker/egress-fragment-child.ts
|
|
3368
3524
|
var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
|
|
3369
3525
|
/** Heartbeat cadence — ~2 minutes of 4 s fragments. */
|
|
3370
3526
|
var FRAGMENT_LOG_EVERY = 30;
|
|
@@ -3372,7 +3528,7 @@ var Fmp4FragmentChild = class {
|
|
|
3372
3528
|
deps;
|
|
3373
3529
|
args;
|
|
3374
3530
|
child = null;
|
|
3375
|
-
splitter = new Fmp4BoxSplitter();
|
|
3531
|
+
splitter = new require_dist.Fmp4BoxSplitter();
|
|
3376
3532
|
stopped = false;
|
|
3377
3533
|
unitsOut = 0;
|
|
3378
3534
|
activeHwAccel = null;
|
|
@@ -3393,13 +3549,13 @@ var Fmp4FragmentChild = class {
|
|
|
3393
3549
|
this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
|
|
3394
3550
|
tags: { deviceId: this.args.deviceId },
|
|
3395
3551
|
meta: {
|
|
3396
|
-
|
|
3552
|
+
sourceId: this.args.sourceId,
|
|
3397
3553
|
decodeHwAccel: requested,
|
|
3398
3554
|
error: require_dist.errMsg(err)
|
|
3399
3555
|
}
|
|
3400
3556
|
});
|
|
3401
3557
|
this.killChild();
|
|
3402
|
-
this.splitter = new Fmp4BoxSplitter();
|
|
3558
|
+
this.splitter = new require_dist.Fmp4BoxSplitter();
|
|
3403
3559
|
this.activeHwAccel = null;
|
|
3404
3560
|
await this.spawnAttempt(null);
|
|
3405
3561
|
}
|
|
@@ -3429,7 +3585,7 @@ var Fmp4FragmentChild = class {
|
|
|
3429
3585
|
this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
|
|
3430
3586
|
tags: { deviceId: this.args.deviceId },
|
|
3431
3587
|
meta: {
|
|
3432
|
-
|
|
3588
|
+
sourceId: this.args.sourceId,
|
|
3433
3589
|
fragmentMs: this.args.fragmentMs,
|
|
3434
3590
|
decodeHwAccel: decodeHwAccel ?? "software",
|
|
3435
3591
|
argv: args.join(" ")
|
|
@@ -3483,7 +3639,7 @@ var Fmp4FragmentChild = class {
|
|
|
3483
3639
|
this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
|
|
3484
3640
|
tags: { deviceId: this.args.deviceId },
|
|
3485
3641
|
meta: {
|
|
3486
|
-
|
|
3642
|
+
sourceId: this.args.sourceId,
|
|
3487
3643
|
bytes: unit.data.length
|
|
3488
3644
|
}
|
|
3489
3645
|
});
|
|
@@ -3491,7 +3647,7 @@ var Fmp4FragmentChild = class {
|
|
|
3491
3647
|
} else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
|
|
3492
3648
|
tags: { deviceId: this.args.deviceId },
|
|
3493
3649
|
meta: {
|
|
3494
|
-
|
|
3650
|
+
sourceId: this.args.sourceId,
|
|
3495
3651
|
unitsOut: this.unitsOut,
|
|
3496
3652
|
bytes: unit.data.length,
|
|
3497
3653
|
subscribers: this.args.plane.subscriberCount
|
|
@@ -3506,7 +3662,7 @@ var Fmp4FragmentChild = class {
|
|
|
3506
3662
|
this.deps.logger.debug("fmp4 fragment child ffmpeg", {
|
|
3507
3663
|
tags: { deviceId: this.args.deviceId },
|
|
3508
3664
|
meta: {
|
|
3509
|
-
|
|
3665
|
+
sourceId: this.args.sourceId,
|
|
3510
3666
|
line: line.trim()
|
|
3511
3667
|
}
|
|
3512
3668
|
});
|
|
@@ -3521,260 +3677,62 @@ var Fmp4FragmentChild = class {
|
|
|
3521
3677
|
this.deps.onChildExit?.(err);
|
|
3522
3678
|
});
|
|
3523
3679
|
child.once("exit", (code, signal) => {
|
|
3524
|
-
if (!settled) {
|
|
3525
|
-
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
|
|
3526
|
-
return;
|
|
3527
|
-
}
|
|
3528
|
-
if (!isCurrent()) return;
|
|
3529
|
-
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
|
|
3530
|
-
this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
|
|
3531
|
-
tags: { deviceId: this.args.deviceId },
|
|
3532
|
-
meta: {
|
|
3533
|
-
|
|
3534
|
-
code,
|
|
3535
|
-
signal,
|
|
3536
|
-
unitsOut: this.unitsOut
|
|
3537
|
-
}
|
|
3538
|
-
});
|
|
3539
|
-
this.args.plane.end("the fragment child exited");
|
|
3540
|
-
this.deps.onChildExit?.(error);
|
|
3541
|
-
});
|
|
3542
|
-
});
|
|
3543
|
-
}
|
|
3544
|
-
/**
|
|
3545
|
-
* The byte stream stopped being splittable. Not recoverable — the splitter
|
|
3546
|
-
* cannot resynchronise mid-box — so the child is a corpse and every consumer
|
|
3547
|
-
* has to be told, loudly, with the reason.
|
|
3548
|
-
*/
|
|
3549
|
-
onFault(reason, wasLive, current, settle) {
|
|
3550
|
-
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
|
|
3551
|
-
this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
|
|
3552
|
-
tags: { deviceId: this.args.deviceId },
|
|
3553
|
-
meta: {
|
|
3554
|
-
brokerId: this.args.brokerId,
|
|
3555
|
-
unitsOut: this.unitsOut,
|
|
3556
|
-
interstitial: this.splitter.discardedInterstitialTypes,
|
|
3557
|
-
reason
|
|
3558
|
-
}
|
|
3559
|
-
});
|
|
3560
|
-
this.killChild();
|
|
3561
|
-
settle(error);
|
|
3562
|
-
if (wasLive && current) {
|
|
3563
|
-
this.args.plane.end("the fragment child produced unsplittable output");
|
|
3564
|
-
this.deps.onChildExit?.(error);
|
|
3565
|
-
}
|
|
3566
|
-
}
|
|
3567
|
-
killChild() {
|
|
3568
|
-
const child = this.child;
|
|
3569
|
-
this.child = null;
|
|
3570
|
-
if (child && !child.killed) try {
|
|
3571
|
-
child.kill("SIGTERM");
|
|
3572
|
-
} catch (err) {
|
|
3573
|
-
this.deps.logger.warn("fmp4 fragment child: kill error", {
|
|
3574
|
-
tags: { deviceId: this.args.deviceId },
|
|
3575
|
-
meta: {
|
|
3576
|
-
brokerId: this.args.brokerId,
|
|
3577
|
-
error: require_dist.errMsg(err)
|
|
3578
|
-
}
|
|
3579
|
-
});
|
|
3580
|
-
}
|
|
3581
|
-
}
|
|
3582
|
-
};
|
|
3583
|
-
//#endregion
|
|
3584
|
-
//#region src/stream-broker/stream-broker/fmp4-fragment-plane.ts
|
|
3585
|
-
/**
|
|
3586
|
-
* Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
|
|
3587
|
-
* {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
|
|
3588
|
-
*
|
|
3589
|
-
* ## Why a plane and not a callback
|
|
3590
|
-
*
|
|
3591
|
-
* The operator's requirement for HKSV was explicit: the live fMP4 source built
|
|
3592
|
-
* for it must be **dual-use**, so a HomeKit-triggered recording also lands in
|
|
3593
|
-
* CamStack as an additional videoclip source alongside the recorder and the NC
|
|
3594
|
-
* clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
|
|
3595
|
-
* (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
|
|
3596
|
-
* a second ffmpeg child of the same camera. So this is the same shape the
|
|
3597
|
-
* broker's other multi-consumer surfaces already have
|
|
3598
|
-
* (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
|
|
3599
|
-
* one producer.
|
|
3600
|
-
*
|
|
3601
|
-
* **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
|
|
3602
|
-
* clip source; both are named here so the seam is not re-invented, and neither
|
|
3603
|
-
* is built.
|
|
3604
|
-
*
|
|
3605
|
-
* ## The init segment is RETAINED
|
|
3606
|
-
*
|
|
3607
|
-
* A subscriber that attaches mid-stream — the clip consumer joining an already
|
|
3608
|
-
* running HKSV session, which is the whole dual-use case — receives the
|
|
3609
|
-
* retained `ftyp`+`moov` as its first packet and then live fragments. Without
|
|
3610
|
-
* retention its fragments are undecodable and the failure looks like a codec
|
|
3611
|
-
* problem.
|
|
3612
|
-
*
|
|
3613
|
-
* ## A slow subscriber is CLOSED, never silently gapped
|
|
3614
|
-
*
|
|
3615
|
-
* `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
|
|
3616
|
-
* click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
|
|
3617
|
-
* `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
|
|
3618
|
-
* shows a clip that fails to play with nothing anywhere saying why. So a
|
|
3619
|
-
* subscription whose queue overflows is ENDED with a reason, loudly, and the
|
|
3620
|
-
* other subscriptions are untouched.
|
|
3621
|
-
*
|
|
3622
|
-
* ## `isLast`
|
|
3623
|
-
*
|
|
3624
|
-
* hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
|
|
3625
|
-
* `isLast` — a generator that finishes without it produces the twelve-second
|
|
3626
|
-
* timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
|
|
3627
|
-
* plane therefore computes it at DELIVERY time: a packet is last when the plane
|
|
3628
|
-
* has ended and nothing remains queued behind it. A subscription that ends
|
|
3629
|
-
* having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
|
|
3630
|
-
* the future delegate must not open an HDS stream it cannot feed.
|
|
3631
|
-
*/
|
|
3632
|
-
var DEFAULT_QUEUE_CAPACITY = 4;
|
|
3633
|
-
var Fmp4FragmentPlane = class {
|
|
3634
|
-
logger;
|
|
3635
|
-
subscriptions = /* @__PURE__ */ new Map();
|
|
3636
|
-
/** The last init unit seen, handed to every later subscriber. */
|
|
3637
|
-
retainedInit = null;
|
|
3638
|
-
ended = false;
|
|
3639
|
-
constructor(logger) {
|
|
3640
|
-
this.logger = logger;
|
|
3641
|
-
}
|
|
3642
|
-
get subscriberCount() {
|
|
3643
|
-
return this.subscriptions.size;
|
|
3644
|
-
}
|
|
3645
|
-
/** True once {@link end} has been called — no further units are accepted. */
|
|
3646
|
-
get isEnded() {
|
|
3647
|
-
return this.ended;
|
|
3648
|
-
}
|
|
3649
|
-
subscribe(input) {
|
|
3650
|
-
const sub = {
|
|
3651
|
-
id: `fmp4-${(0, node_crypto.randomUUID)()}`,
|
|
3652
|
-
tag: input.tag,
|
|
3653
|
-
subscribedAt: Date.now(),
|
|
3654
|
-
capacity: Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY),
|
|
3655
|
-
queue: [],
|
|
3656
|
-
delivered: 0,
|
|
3657
|
-
closedReason: null,
|
|
3658
|
-
wake: null,
|
|
3659
|
-
iterating: false
|
|
3660
|
-
};
|
|
3661
|
-
this.subscriptions.set(sub.id, sub);
|
|
3662
|
-
if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
|
|
3663
|
-
if (this.ended) this.closeSubscription(sub, "ended");
|
|
3664
|
-
this.logger?.info("fmp4 plane: subscribed", { meta: {
|
|
3665
|
-
subscriptionId: sub.id,
|
|
3666
|
-
tag: sub.tag,
|
|
3667
|
-
hasRetainedInit: this.retainedInit !== null
|
|
3668
|
-
} });
|
|
3669
|
-
return this.facade(sub);
|
|
3670
|
-
}
|
|
3671
|
-
/**
|
|
3672
|
-
* Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
|
|
3673
|
-
* emits exactly one per child, and a second means the child was respawned, in
|
|
3674
|
-
* which case the old one describes a stream that no longer exists.
|
|
3675
|
-
*/
|
|
3676
|
-
publish(unit) {
|
|
3677
|
-
if (this.ended) return;
|
|
3678
|
-
if (unit.kind === "init") this.retainedInit = unit;
|
|
3679
|
-
for (const sub of this.subscriptions.values()) {
|
|
3680
|
-
if (sub.closedReason !== null) continue;
|
|
3681
|
-
this.enqueue(sub, unit);
|
|
3682
|
-
}
|
|
3680
|
+
if (!settled) {
|
|
3681
|
+
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
if (!isCurrent()) return;
|
|
3685
|
+
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
|
|
3686
|
+
this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
|
|
3687
|
+
tags: { deviceId: this.args.deviceId },
|
|
3688
|
+
meta: {
|
|
3689
|
+
sourceId: this.args.sourceId,
|
|
3690
|
+
code,
|
|
3691
|
+
signal,
|
|
3692
|
+
unitsOut: this.unitsOut
|
|
3693
|
+
}
|
|
3694
|
+
});
|
|
3695
|
+
this.args.plane.end("the fragment child exited");
|
|
3696
|
+
this.deps.onChildExit?.(error);
|
|
3697
|
+
});
|
|
3698
|
+
});
|
|
3683
3699
|
}
|
|
3684
3700
|
/**
|
|
3685
|
-
* The
|
|
3686
|
-
*
|
|
3701
|
+
* The byte stream stopped being splittable. Not recoverable — the splitter
|
|
3702
|
+
* cannot resynchronise mid-box — so the child is a corpse and every consumer
|
|
3703
|
+
* has to be told, loudly, with the reason.
|
|
3687
3704
|
*/
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
this.
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
listSubscribers() {
|
|
3698
|
-
return [...this.subscriptions.values()].map((s) => ({
|
|
3699
|
-
tag: s.tag,
|
|
3700
|
-
subscribedAt: s.subscribedAt,
|
|
3701
|
-
delivered: s.delivered,
|
|
3702
|
-
closedReason: s.closedReason
|
|
3703
|
-
}));
|
|
3704
|
-
}
|
|
3705
|
-
/** End and forget everything. Idempotent. */
|
|
3706
|
-
dispose() {
|
|
3707
|
-
this.end("disposed");
|
|
3708
|
-
this.subscriptions.clear();
|
|
3709
|
-
this.retainedInit = null;
|
|
3710
|
-
}
|
|
3711
|
-
enqueue(sub, unit) {
|
|
3712
|
-
if (sub.queue.length >= sub.capacity) {
|
|
3713
|
-
this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
|
|
3714
|
-
subscriptionId: sub.id,
|
|
3715
|
-
tag: sub.tag,
|
|
3716
|
-
capacity: sub.capacity,
|
|
3717
|
-
delivered: sub.delivered
|
|
3718
|
-
} });
|
|
3719
|
-
this.closeSubscription(sub, "slow-consumer");
|
|
3720
|
-
return;
|
|
3721
|
-
}
|
|
3722
|
-
sub.queue.push({
|
|
3723
|
-
kind: unit.kind,
|
|
3724
|
-
data: unit.data,
|
|
3725
|
-
sequence: unit.sequence,
|
|
3726
|
-
isLast: false
|
|
3727
|
-
});
|
|
3728
|
-
this.wake(sub);
|
|
3729
|
-
}
|
|
3730
|
-
closeSubscription(sub, reason) {
|
|
3731
|
-
if (sub.closedReason !== null) return;
|
|
3732
|
-
sub.closedReason = reason;
|
|
3733
|
-
if (reason === "slow-consumer") sub.queue.length = 0;
|
|
3734
|
-
this.wake(sub);
|
|
3735
|
-
}
|
|
3736
|
-
wake(sub) {
|
|
3737
|
-
const resume = sub.wake;
|
|
3738
|
-
sub.wake = null;
|
|
3739
|
-
resume?.();
|
|
3740
|
-
}
|
|
3741
|
-
facade(sub) {
|
|
3742
|
-
const plane = this;
|
|
3743
|
-
return {
|
|
3744
|
-
id: sub.id,
|
|
3745
|
-
tag: sub.tag,
|
|
3746
|
-
get delivered() {
|
|
3747
|
-
return sub.delivered;
|
|
3748
|
-
},
|
|
3749
|
-
get closedReason() {
|
|
3750
|
-
return sub.closedReason;
|
|
3751
|
-
},
|
|
3752
|
-
packets: () => plane.iterate(sub),
|
|
3753
|
-
release: () => {
|
|
3754
|
-
plane.closeSubscription(sub, "released");
|
|
3755
|
-
plane.subscriptions.delete(sub.id);
|
|
3705
|
+
onFault(reason, wasLive, current, settle) {
|
|
3706
|
+
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
|
|
3707
|
+
this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
|
|
3708
|
+
tags: { deviceId: this.args.deviceId },
|
|
3709
|
+
meta: {
|
|
3710
|
+
sourceId: this.args.sourceId,
|
|
3711
|
+
unitsOut: this.unitsOut,
|
|
3712
|
+
interstitial: this.splitter.discardedInterstitialTypes,
|
|
3713
|
+
reason
|
|
3756
3714
|
}
|
|
3757
|
-
};
|
|
3715
|
+
});
|
|
3716
|
+
this.killChild();
|
|
3717
|
+
settle(error);
|
|
3718
|
+
if (wasLive && current) {
|
|
3719
|
+
this.args.plane.end("the fragment child produced unsplittable output");
|
|
3720
|
+
this.deps.onChildExit?.(error);
|
|
3721
|
+
}
|
|
3758
3722
|
}
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
sub.delivered += 1;
|
|
3773
|
-
yield {
|
|
3774
|
-
...next,
|
|
3775
|
-
isLast
|
|
3776
|
-
};
|
|
3777
|
-
if (isLast) return;
|
|
3723
|
+
killChild() {
|
|
3724
|
+
const child = this.child;
|
|
3725
|
+
this.child = null;
|
|
3726
|
+
if (child && !child.killed) try {
|
|
3727
|
+
child.kill("SIGTERM");
|
|
3728
|
+
} catch (err) {
|
|
3729
|
+
this.deps.logger.warn("fmp4 fragment child: kill error", {
|
|
3730
|
+
tags: { deviceId: this.args.deviceId },
|
|
3731
|
+
meta: {
|
|
3732
|
+
sourceId: this.args.sourceId,
|
|
3733
|
+
error: require_dist.errMsg(err)
|
|
3734
|
+
}
|
|
3735
|
+
});
|
|
3778
3736
|
}
|
|
3779
3737
|
}
|
|
3780
3738
|
};
|
|
@@ -4604,7 +4562,7 @@ var EgressTranscodeManager = class {
|
|
|
4604
4562
|
*
|
|
4605
4563
|
* The child owns the software retry and the liveness reporting itself, in the
|
|
4606
4564
|
* same shape as the other two transports; this wrapper is the adapter, not
|
|
4607
|
-
* the logic. See `
|
|
4565
|
+
* the logic. See the primitive's `ffmpeg/fmp4-fragment-child.ts`.
|
|
4608
4566
|
*/
|
|
4609
4567
|
createFragmentChild(spec, fragments) {
|
|
4610
4568
|
const child = new Fmp4FragmentChild({
|
|
@@ -4614,7 +4572,7 @@ var EgressTranscodeManager = class {
|
|
|
4614
4572
|
...this.opts.fragmentFirstUnitTimeoutMs === void 0 ? {} : { firstUnitTimeoutMs: this.opts.fragmentFirstUnitTimeoutMs },
|
|
4615
4573
|
...spec.onDied ? { onChildExit: spec.onDied } : {}
|
|
4616
4574
|
}, {
|
|
4617
|
-
|
|
4575
|
+
sourceId: spec.brokerId,
|
|
4618
4576
|
deviceId: spec.deviceId,
|
|
4619
4577
|
fragmentMs: fragments.fragmentMs,
|
|
4620
4578
|
invocation: spec.invocation,
|
|
@@ -9863,6 +9821,29 @@ var StreamBroker = class StreamBroker {
|
|
|
9863
9821
|
* right model based on per-camera codec + sample rate.
|
|
9864
9822
|
*/
|
|
9865
9823
|
audioTrackInfo = null;
|
|
9824
|
+
/**
|
|
9825
|
+
* BROKER-SIDE AUDIO MUTE (D83) — operator policy, not a stream fact.
|
|
9826
|
+
*
|
|
9827
|
+
* While true this broker distributes NO audio on any plane it serves. It is
|
|
9828
|
+
* applied at the three points audio ENTERS the broker (`onAudioRtp`,
|
|
9829
|
+
* `pushEncodedPacket`) and re-checked at the single point it LEAVES
|
|
9830
|
+
* (`fanOutEncoded`), so a packet already inside a decode session when the
|
|
9831
|
+
* mute lands cannot escape.
|
|
9832
|
+
*
|
|
9833
|
+
* Deliberately NOT cleared by `resetStreamState` / a re-dial: it is device
|
|
9834
|
+
* policy that outlives any one connection. `StreamBrokerManager` re-applies
|
|
9835
|
+
* it to every freshly created broker for the same reason.
|
|
9836
|
+
*/
|
|
9837
|
+
audioMuted = false;
|
|
9838
|
+
/** Audio units dropped by the mute since the current dial. Surfaced in stats. */
|
|
9839
|
+
audioMutedDropped = 0;
|
|
9840
|
+
/**
|
|
9841
|
+
* One-shot per dial: the mute's drop is announced the FIRST time it actually
|
|
9842
|
+
* drops something, not once per packet. A broker created while already muted
|
|
9843
|
+
* has no transition to log, and a silent drop reads as "the camera has no
|
|
9844
|
+
* microphone" — which is the confusion this whole row exists to remove.
|
|
9845
|
+
*/
|
|
9846
|
+
audioMutedDropLogged = false;
|
|
9866
9847
|
reconnectTimer;
|
|
9867
9848
|
placeholderTimer;
|
|
9868
9849
|
reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
|
|
@@ -10414,6 +10395,69 @@ var StreamBroker = class StreamBroker {
|
|
|
10414
10395
|
this.logger?.warn("source-restart observer threw", { meta: { error: require_dist.errMsg(err) } });
|
|
10415
10396
|
}
|
|
10416
10397
|
}
|
|
10398
|
+
/**
|
|
10399
|
+
* Turn this broker's audio plane off (or back on).
|
|
10400
|
+
*
|
|
10401
|
+
* Idempotent: setting the value it already holds does nothing at all — in
|
|
10402
|
+
* particular it does NOT churn the RTSP sessions, so the manager can call
|
|
10403
|
+
* this on every broker creation and every reconcile pass without cost.
|
|
10404
|
+
*
|
|
10405
|
+
* A real transition closes the restreamer's audio-bearing pull sessions.
|
|
10406
|
+
* That is not incidental: the recorder pulls this restreamer with `-map 0`,
|
|
10407
|
+
* so its ffmpeg maps whatever the SDP declared at CONNECT time. Dropping the
|
|
10408
|
+
* RTP while leaving an `m=audio` in its SDP would leave it waiting on an
|
|
10409
|
+
* audio stream that never delivers (the mp4 muxer holds video up to
|
|
10410
|
+
* `max_interleave_delta`). Re-pulling is what makes the recording honestly
|
|
10411
|
+
* audio-less rather than audio-declared-and-starved. The recorder's
|
|
10412
|
+
* `SegmentWriter` respawns on exit with bounded backoff, so the cost is one
|
|
10413
|
+
* segment boundary per flip.
|
|
10414
|
+
*/
|
|
10415
|
+
setAudioMuted(muted) {
|
|
10416
|
+
if (this.audioMuted === muted) return;
|
|
10417
|
+
this.audioMuted = muted;
|
|
10418
|
+
this.audioMutedDropped = 0;
|
|
10419
|
+
this.audioMutedDropLogged = false;
|
|
10420
|
+
const closedPulls = this.rtspRestreamer.setAudioMuted(muted);
|
|
10421
|
+
this.logger?.info(muted ? "audio plane MUTED broker-side — live and recording go silent" : "audio plane UNMUTED broker-side — audio distribution resumes", {
|
|
10422
|
+
tags: { deviceId: this.numericDeviceId },
|
|
10423
|
+
meta: {
|
|
10424
|
+
brokerId: this.deviceId,
|
|
10425
|
+
audioCodec: this.audioCodec,
|
|
10426
|
+
hasAudioTrack: this.audioTrackInfo !== null,
|
|
10427
|
+
closedPulls
|
|
10428
|
+
}
|
|
10429
|
+
});
|
|
10430
|
+
}
|
|
10431
|
+
/** The broker's own audio-plane policy. `true` = distributing no audio. */
|
|
10432
|
+
isAudioMuted() {
|
|
10433
|
+
return this.audioMuted;
|
|
10434
|
+
}
|
|
10435
|
+
/**
|
|
10436
|
+
* True when the current connection is muxing audio at all — the OBSERVED
|
|
10437
|
+
* half of the D83 audio-plane reconcile. Read off the SDP/track snapshot
|
|
10438
|
+
* rather than a packet counter so a camera that negotiated an audio track
|
|
10439
|
+
* and has simply been quiet does not read as a missing mux.
|
|
10440
|
+
*/
|
|
10441
|
+
hasAudioTrack() {
|
|
10442
|
+
return this.audioTrackInfo !== null;
|
|
10443
|
+
}
|
|
10444
|
+
/**
|
|
10445
|
+
* Account for one audio unit the mute threw away, announcing the FIRST one
|
|
10446
|
+
* per dial. Never per-packet: at 50 pps a line each would be its own outage.
|
|
10447
|
+
*/
|
|
10448
|
+
dropMutedAudio(where) {
|
|
10449
|
+
this.audioMutedDropped += 1;
|
|
10450
|
+
if (this.audioMutedDropLogged) return;
|
|
10451
|
+
this.audioMutedDropLogged = true;
|
|
10452
|
+
this.logger?.info("audio muted broker-side — dropping this camera’s audio plane", {
|
|
10453
|
+
tags: { deviceId: this.numericDeviceId },
|
|
10454
|
+
meta: {
|
|
10455
|
+
brokerId: this.deviceId,
|
|
10456
|
+
where,
|
|
10457
|
+
audioCodec: this.audioCodec
|
|
10458
|
+
}
|
|
10459
|
+
});
|
|
10460
|
+
}
|
|
10417
10461
|
/** Set whether pre-buffer counts as demand. When false and no other consumers, the broker suspends. */
|
|
10418
10462
|
setPreBufferEnabled(enabled) {
|
|
10419
10463
|
this._preBufferEnabled = enabled;
|
|
@@ -10686,6 +10730,10 @@ var StreamBroker = class StreamBroker {
|
|
|
10686
10730
|
}
|
|
10687
10731
|
this.resetPushStallTimer();
|
|
10688
10732
|
}
|
|
10733
|
+
if (packet.type === "audio" && this.audioMuted) {
|
|
10734
|
+
this.dropMutedAudio("push-encoded");
|
|
10735
|
+
return;
|
|
10736
|
+
}
|
|
10689
10737
|
if (packet.type === "audio" && this.source?.type === "push" && this.audioCodecSession) {
|
|
10690
10738
|
const codec = packet.codec.toLowerCase();
|
|
10691
10739
|
if (codec !== "pcmu" && codec !== "pcma") {
|
|
@@ -10966,6 +11014,10 @@ var StreamBroker = class StreamBroker {
|
|
|
10966
11014
|
* safe one.
|
|
10967
11015
|
*/
|
|
10968
11016
|
fanOutEncoded(packet) {
|
|
11017
|
+
if (packet.type === "audio" && this.audioMuted) {
|
|
11018
|
+
this.dropMutedAudio("encoded-fanout");
|
|
11019
|
+
return;
|
|
11020
|
+
}
|
|
10969
11021
|
for (const [cb, entry] of this.encodedCallbacks) try {
|
|
10970
11022
|
cb(packet);
|
|
10971
11023
|
entry.packetsDelivered += 1;
|
|
@@ -11244,7 +11296,9 @@ var StreamBroker = class StreamBroker {
|
|
|
11244
11296
|
preBufferMs: this.preBuffer.getBufferedDurationMs(),
|
|
11245
11297
|
preBufferPackets: this.preBuffer.getPacketCount(),
|
|
11246
11298
|
decoderNodeId: this.frameHandlePlane?.decoderNodeIds()[0] ?? null,
|
|
11247
|
-
audio: this.audioTrackInfo ?? null
|
|
11299
|
+
audio: this.audioTrackInfo ?? null,
|
|
11300
|
+
audioMuted: this.audioMuted,
|
|
11301
|
+
audioMutedDropped: this.audioMutedDropped
|
|
11248
11302
|
};
|
|
11249
11303
|
}
|
|
11250
11304
|
/**
|
|
@@ -11679,6 +11733,10 @@ var StreamBroker = class StreamBroker {
|
|
|
11679
11733
|
onAudioRtp: (rtpData) => {
|
|
11680
11734
|
if (this.stopping) return;
|
|
11681
11735
|
this.kickRtpActivityWatchdog();
|
|
11736
|
+
if (this.audioMuted) {
|
|
11737
|
+
this.dropMutedAudio("rtsp-audio-rtp");
|
|
11738
|
+
return;
|
|
11739
|
+
}
|
|
11682
11740
|
this.audioRtpSeen++;
|
|
11683
11741
|
if (this.audioRtpSeen === 1 || this.audioRtpSeen === 50) this.logger?.info("audio: rtp packet received", { meta: {
|
|
11684
11742
|
seq: this.audioRtpSeen,
|
|
@@ -12341,6 +12399,79 @@ function hexToBytes(hex) {
|
|
|
12341
12399
|
}
|
|
12342
12400
|
return out;
|
|
12343
12401
|
}
|
|
12402
|
+
/**
|
|
12403
|
+
* Minimum gap between two re-dials of the same device. A re-dial costs a
|
|
12404
|
+
* ~2 s video interruption on every source of the camera; at the 30 s
|
|
12405
|
+
* reconcile cadence, an un-cooled loop against a camera whose audio genuinely
|
|
12406
|
+
* cannot come back would be a permanent stutter.
|
|
12407
|
+
*/
|
|
12408
|
+
var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
|
|
12409
|
+
var EMPTY_AUDIO_PLANE_STATE = {
|
|
12410
|
+
disagreements: 0,
|
|
12411
|
+
redials: 0,
|
|
12412
|
+
lastRedialMs: 0,
|
|
12413
|
+
gaveUp: false
|
|
12414
|
+
};
|
|
12415
|
+
/**
|
|
12416
|
+
* Decide what one pass should do. Pure: no clock, no I/O, no broker.
|
|
12417
|
+
*
|
|
12418
|
+
* `nowMs` is passed in rather than read so the cooldown is testable without
|
|
12419
|
+
* waiting five minutes for it.
|
|
12420
|
+
*/
|
|
12421
|
+
function decideAudioPlaneAction(observation, state, nowMs) {
|
|
12422
|
+
switch (observation.kind) {
|
|
12423
|
+
case "broker-muted":
|
|
12424
|
+
case "no-live-source":
|
|
12425
|
+
case "audio-present":
|
|
12426
|
+
case "declared-off": return {
|
|
12427
|
+
action: { kind: "none" },
|
|
12428
|
+
nextState: EMPTY_AUDIO_PLANE_STATE
|
|
12429
|
+
};
|
|
12430
|
+
case "read-failed":
|
|
12431
|
+
case "audio-unknown": return {
|
|
12432
|
+
action: { kind: "none" },
|
|
12433
|
+
nextState: state
|
|
12434
|
+
};
|
|
12435
|
+
case "declared-on": {
|
|
12436
|
+
if (state.gaveUp) return {
|
|
12437
|
+
action: { kind: "none" },
|
|
12438
|
+
nextState: state
|
|
12439
|
+
};
|
|
12440
|
+
const disagreements = state.disagreements + 1;
|
|
12441
|
+
if (disagreements < 2) return {
|
|
12442
|
+
action: { kind: "await-second-pass" },
|
|
12443
|
+
nextState: {
|
|
12444
|
+
...state,
|
|
12445
|
+
disagreements
|
|
12446
|
+
}
|
|
12447
|
+
};
|
|
12448
|
+
if (state.redials >= 3) return {
|
|
12449
|
+
action: { kind: "give-up" },
|
|
12450
|
+
nextState: {
|
|
12451
|
+
...state,
|
|
12452
|
+
disagreements,
|
|
12453
|
+
gaveUp: true
|
|
12454
|
+
}
|
|
12455
|
+
};
|
|
12456
|
+
if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
|
|
12457
|
+
action: { kind: "cooldown" },
|
|
12458
|
+
nextState: {
|
|
12459
|
+
...state,
|
|
12460
|
+
disagreements: 2
|
|
12461
|
+
}
|
|
12462
|
+
};
|
|
12463
|
+
return {
|
|
12464
|
+
action: { kind: "redial" },
|
|
12465
|
+
nextState: {
|
|
12466
|
+
disagreements: 0,
|
|
12467
|
+
redials: state.redials + 1,
|
|
12468
|
+
lastRedialMs: nowMs,
|
|
12469
|
+
gaveUp: false
|
|
12470
|
+
}
|
|
12471
|
+
};
|
|
12472
|
+
}
|
|
12473
|
+
}
|
|
12474
|
+
}
|
|
12344
12475
|
//#endregion
|
|
12345
12476
|
//#region src/stream-broker/stream-broker/transcode-pipeline.ts
|
|
12346
12477
|
/**
|
|
@@ -12825,12 +12956,31 @@ var DeviceOverrideSchema = require_dist.object({
|
|
|
12825
12956
|
preBuffer: require_dist.record(require_dist.string(), StreamPreBufferSchema).optional(),
|
|
12826
12957
|
joinBurstMaxSpanMs: require_dist.record(require_dist.string(), require_dist.number()).optional(),
|
|
12827
12958
|
streamingDebug: require_dist.boolean().optional(),
|
|
12959
|
+
audioMuted: require_dist.boolean().optional(),
|
|
12828
12960
|
webrtcDebug: require_dist.boolean().optional(),
|
|
12829
12961
|
derivedStreams: require_dist.record(require_dist.string(), DerivedStreamSchema).optional(),
|
|
12830
12962
|
hwaccel: require_dist.record(require_dist.string(), FfmpegHwAccelSettingSchema).optional(),
|
|
12831
12963
|
sourceParser: require_dist.record(require_dist.string(), SourceParserSettingSchema).optional(),
|
|
12832
12964
|
sourceParserArgs: require_dist.record(require_dist.string(), SourceParserArgsEntrySchema).optional()
|
|
12833
12965
|
});
|
|
12966
|
+
/**
|
|
12967
|
+
* Read `audioEnabled` off a `privacyMask.getStatus` payload (D83 reconcile).
|
|
12968
|
+
*
|
|
12969
|
+
* `getStatus` is the one cap method whose output is the generic device-status
|
|
12970
|
+
* shape rather than the cap's own schema, so it arrives untyped — this is the
|
|
12971
|
+
* documented type boundary, and a guard rather than a cast.
|
|
12972
|
+
*
|
|
12973
|
+
* `null` means UNKNOWN, and unknown must never become "audio is on": that is
|
|
12974
|
+
* the read that would re-dial a healthy stream on the strength of a payload
|
|
12975
|
+
* we could not parse. The camera says `null` itself when it has no microphone
|
|
12976
|
+
* control (`supportsAudioMute === false`), which lands in the same bucket for
|
|
12977
|
+
* the same reason.
|
|
12978
|
+
*/
|
|
12979
|
+
function readDeclaredAudioEnabled(v) {
|
|
12980
|
+
if (v === null || typeof v !== "object") return null;
|
|
12981
|
+
const raw = { ...v }["audioEnabled"];
|
|
12982
|
+
return typeof raw === "boolean" ? raw : null;
|
|
12983
|
+
}
|
|
12834
12984
|
/** Whole-blob schema for the persisted `deviceOverrides` store key:
|
|
12835
12985
|
* numeric-string deviceId → DeviceOverride (includes `derivedStreams`). */
|
|
12836
12986
|
var DeviceOverridesMapSchema = require_dist.record(require_dist.string(), DeviceOverrideSchema);
|
|
@@ -12954,6 +13104,11 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
12954
13104
|
/** Per-device overrides (pre-buffer + legacy). */
|
|
12955
13105
|
deviceOverrides = /* @__PURE__ */ new Map();
|
|
12956
13106
|
/**
|
|
13107
|
+
* D83 audio-plane reconcile memory, per device. Absent = agreeing; entries
|
|
13108
|
+
* are deleted the moment a pass agrees, so a healthy fleet holds none.
|
|
13109
|
+
*/
|
|
13110
|
+
audioPlaneState = /* @__PURE__ */ new Map();
|
|
13111
|
+
/**
|
|
12957
13112
|
* `deviceId:camStreamId` keys for which the `native-udp` → `native-tcp`
|
|
12958
13113
|
* fallback warning has already been logged, so the warn fires once per stream
|
|
12959
13114
|
* rather than on every source (re)build / dial.
|
|
@@ -14301,7 +14456,96 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
14301
14456
|
this.disabledDevices.add(d.id);
|
|
14302
14457
|
await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
|
|
14303
14458
|
} else this.disabledDevices.delete(d.id);
|
|
14304
|
-
|
|
14459
|
+
const live = cameras.filter((d) => !d.disabled);
|
|
14460
|
+
await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id)));
|
|
14461
|
+
await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
|
|
14462
|
+
const liveIds = new Set(live.map((d) => d.id));
|
|
14463
|
+
for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
|
|
14464
|
+
}
|
|
14465
|
+
/**
|
|
14466
|
+
* ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
|
|
14467
|
+
* for the decision rules; this half only gathers and acts.
|
|
14468
|
+
*
|
|
14469
|
+
* Every branch that changes nothing still says so at debug — the whole
|
|
14470
|
+
* failure this repairs was invisible, and a reconcile whose "I looked and
|
|
14471
|
+
* everything was fine" is unobservable is a reconcile nobody can trust
|
|
14472
|
+
* after the next incident.
|
|
14473
|
+
*/
|
|
14474
|
+
async reconcileDeviceAudioPlane(deviceId) {
|
|
14475
|
+
const observation = await this.observeAudioPlane(deviceId);
|
|
14476
|
+
const { action, nextState } = decideAudioPlaneAction(observation, this.audioPlaneState.get(deviceId) ?? EMPTY_AUDIO_PLANE_STATE, Date.now());
|
|
14477
|
+
if (nextState === EMPTY_AUDIO_PLANE_STATE) this.audioPlaneState.delete(deviceId);
|
|
14478
|
+
else this.audioPlaneState.set(deviceId, nextState);
|
|
14479
|
+
const meta = {
|
|
14480
|
+
observed: observation.kind,
|
|
14481
|
+
disagreements: nextState.disagreements,
|
|
14482
|
+
redials: nextState.redials
|
|
14483
|
+
};
|
|
14484
|
+
switch (action.kind) {
|
|
14485
|
+
case "none": return;
|
|
14486
|
+
case "await-second-pass":
|
|
14487
|
+
this.logger.info("audio-plane reconcile: camera declares audio but no broker carries a track — confirming on the next pass", {
|
|
14488
|
+
tags: { deviceId },
|
|
14489
|
+
meta
|
|
14490
|
+
});
|
|
14491
|
+
return;
|
|
14492
|
+
case "cooldown":
|
|
14493
|
+
this.logger.debug("audio-plane reconcile: disagreement held — re-dial cooling down", {
|
|
14494
|
+
tags: { deviceId },
|
|
14495
|
+
meta: {
|
|
14496
|
+
...meta,
|
|
14497
|
+
cooldownMs: AUDIO_PLANE_REDIAL_COOLDOWN_MS
|
|
14498
|
+
}
|
|
14499
|
+
});
|
|
14500
|
+
return;
|
|
14501
|
+
case "give-up":
|
|
14502
|
+
this.logger.warn("audio-plane reconcile: giving up — the camera declares audio and re-dialing has not brought a track back", {
|
|
14503
|
+
tags: { deviceId },
|
|
14504
|
+
meta: {
|
|
14505
|
+
...meta,
|
|
14506
|
+
maxRedials: 3
|
|
14507
|
+
}
|
|
14508
|
+
});
|
|
14509
|
+
return;
|
|
14510
|
+
case "redial":
|
|
14511
|
+
this.logger.info("audio-plane reconcile: audio declared but absent on every source — re-dialing", {
|
|
14512
|
+
tags: { deviceId },
|
|
14513
|
+
meta
|
|
14514
|
+
});
|
|
14515
|
+
this.redialDeviceSources(deviceId, "audio-plane-reconcile");
|
|
14516
|
+
return;
|
|
14517
|
+
}
|
|
14518
|
+
}
|
|
14519
|
+
/**
|
|
14520
|
+
* The gather half. Cheap questions first: the expensive device read happens
|
|
14521
|
+
* ONLY when the free in-memory answer already looks wrong.
|
|
14522
|
+
*/
|
|
14523
|
+
async observeAudioPlane(deviceId) {
|
|
14524
|
+
if (this.deviceOverrides.get(deviceId)?.audioMuted === true) return { kind: "broker-muted" };
|
|
14525
|
+
const streams = this.cameraStreams.get(deviceId);
|
|
14526
|
+
if (!streams) return { kind: "no-live-source" };
|
|
14527
|
+
const sources = [];
|
|
14528
|
+
for (const [camStreamId, entry] of streams) {
|
|
14529
|
+
if (entry.kind === "derived") continue;
|
|
14530
|
+
const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
|
|
14531
|
+
if (broker && broker.status === "streaming") sources.push(broker);
|
|
14532
|
+
}
|
|
14533
|
+
if (sources.length === 0) return { kind: "no-live-source" };
|
|
14534
|
+
if (sources.some((b) => b.hasAudioTrack())) return { kind: "audio-present" };
|
|
14535
|
+
if (!this.api) return { kind: "read-failed" };
|
|
14536
|
+
let status;
|
|
14537
|
+
try {
|
|
14538
|
+
status = await this.api.privacyMask.getStatus.query({ deviceId });
|
|
14539
|
+
} catch (err) {
|
|
14540
|
+
this.logger.debug("audio-plane reconcile: device audio read failed — changing nothing", {
|
|
14541
|
+
tags: { deviceId },
|
|
14542
|
+
meta: { error: require_dist.errMsg(err) }
|
|
14543
|
+
});
|
|
14544
|
+
return { kind: "read-failed" };
|
|
14545
|
+
}
|
|
14546
|
+
const declared = readDeclaredAudioEnabled(status);
|
|
14547
|
+
if (declared === null) return { kind: "audio-unknown" };
|
|
14548
|
+
return declared ? { kind: "declared-on" } : { kind: "declared-off" };
|
|
14305
14549
|
}
|
|
14306
14550
|
/** Release every live broker of a disabled device (idempotent — later poll
|
|
14307
14551
|
* ticks find none and no-op). `logTransition` marks the disable EDGE. */
|
|
@@ -16260,6 +16504,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16260
16504
|
broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
|
|
16261
16505
|
broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
|
|
16262
16506
|
this.applyPreBufferConfig(broker, deviceId, camStreamId);
|
|
16507
|
+
this.applyAudioMuteConfig(broker, deviceId);
|
|
16263
16508
|
this.applyJoinBurstBound(broker, deviceId, camStreamId);
|
|
16264
16509
|
this.applyClipRetention(deviceId);
|
|
16265
16510
|
if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
|
|
@@ -16455,6 +16700,72 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
16455
16700
|
broker.setPreBufferEnabled(defaultWarm);
|
|
16456
16701
|
broker.setPreBufferDuration(defaultWarm ? this.defaultPreBufferSec : 0);
|
|
16457
16702
|
}
|
|
16703
|
+
/** Push the persisted mute onto a broker. Idempotent — safe on every pass. */
|
|
16704
|
+
applyAudioMuteConfig(broker, deviceId) {
|
|
16705
|
+
broker.setAudioMuted(this.deviceOverrides.get(deviceId)?.audioMuted === true);
|
|
16706
|
+
}
|
|
16707
|
+
/** Every live broker of a device, derived and source alike. */
|
|
16708
|
+
brokersOfDevice(deviceId) {
|
|
16709
|
+
const streams = this.cameraStreams.get(deviceId);
|
|
16710
|
+
if (!streams) return [];
|
|
16711
|
+
const found = [];
|
|
16712
|
+
for (const camStreamId of streams.keys()) {
|
|
16713
|
+
const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
|
|
16714
|
+
if (broker) found.push(broker);
|
|
16715
|
+
}
|
|
16716
|
+
return found;
|
|
16717
|
+
}
|
|
16718
|
+
/**
|
|
16719
|
+
* `streamBroker.getDeviceAudioMute` — read the device's audio-plane policy.
|
|
16720
|
+
*
|
|
16721
|
+
* The STORE is the answer, not the brokers: the policy applies to brokers
|
|
16722
|
+
* that do not exist yet, so a camera with nothing dialled is still muted.
|
|
16723
|
+
* `appliedBrokers` reports how much of it is presently in force, and is
|
|
16724
|
+
* diagnostic only — a switch that read it as the state would flip itself
|
|
16725
|
+
* off every time the camera went idle.
|
|
16726
|
+
*/
|
|
16727
|
+
getDeviceAudioMute(input) {
|
|
16728
|
+
return {
|
|
16729
|
+
muted: this.deviceOverrides.get(input.deviceId)?.audioMuted === true,
|
|
16730
|
+
appliedBrokers: this.brokersOfDevice(input.deviceId).filter((b) => b.isAudioMuted()).length
|
|
16731
|
+
};
|
|
16732
|
+
}
|
|
16733
|
+
/**
|
|
16734
|
+
* Set the device's audio-plane policy: persist it, then apply it to every
|
|
16735
|
+
* live broker — including DERIVED ones, whose restreamer would otherwise
|
|
16736
|
+
* keep serving an `m=audio` grafted from its transcode egress.
|
|
16737
|
+
*
|
|
16738
|
+
* Persist BEFORE applying. If the process dies between the two, the next
|
|
16739
|
+
* boot re-applies from the store and the camera is silent as asked; the
|
|
16740
|
+
* other order loses the operator's decision and the camera is audible again
|
|
16741
|
+
* with the switch still reading off.
|
|
16742
|
+
*/
|
|
16743
|
+
async setDeviceAudioMute(input) {
|
|
16744
|
+
const { deviceId, muted } = input;
|
|
16745
|
+
const prior = this.deviceOverrides.get(deviceId)?.audioMuted === true;
|
|
16746
|
+
const next = {
|
|
16747
|
+
...this.deviceOverrides.get(deviceId),
|
|
16748
|
+
audioMuted: muted
|
|
16749
|
+
};
|
|
16750
|
+
this.deviceOverrides.set(deviceId, next);
|
|
16751
|
+
this.deviceOverridePersister?.(this.deviceOverrides);
|
|
16752
|
+
const brokers = this.brokersOfDevice(deviceId);
|
|
16753
|
+
for (const broker of brokers) broker.setAudioMuted(muted);
|
|
16754
|
+
if (prior !== muted) {
|
|
16755
|
+
this.logger.info("broker audio mute set", {
|
|
16756
|
+
tags: { deviceId },
|
|
16757
|
+
meta: {
|
|
16758
|
+
muted,
|
|
16759
|
+
brokers: brokers.length
|
|
16760
|
+
}
|
|
16761
|
+
});
|
|
16762
|
+
this.audioPlaneState.delete(deviceId);
|
|
16763
|
+
}
|
|
16764
|
+
return Promise.resolve({
|
|
16765
|
+
muted,
|
|
16766
|
+
appliedBrokers: brokers.filter((b) => b.isAudioMuted()).length
|
|
16767
|
+
});
|
|
16768
|
+
}
|
|
16458
16769
|
async fanOutRegistration(_deviceId, brokerId, _cam, broker) {
|
|
16459
16770
|
if (this.webrtcServer) this.webrtcServer.registerBroker(brokerId, broker);
|
|
16460
16771
|
}
|