@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.
Files changed (36) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +3 -2
  4. package/dist/detection-pipeline/index.mjs +3 -2
  5. package/dist/{dist-DOu93i_g.mjs → dist-BDkEhx4Z.mjs} +692 -123
  6. package/dist/{dist-BuFE5rOK.js → dist-QyZ7QSRP.js} +697 -122
  7. package/dist/{event-loop-stall-monitor-L08yG6bB.js → event-loop-stall-monitor-COEKVBQK.js} +14 -7
  8. package/dist/{event-loop-stall-monitor-CShgQE6l.mjs → event-loop-stall-monitor-IAuNEdmG.mjs} +14 -7
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +3 -3
  12. package/dist/pipeline-runner/index.mjs +3 -3
  13. package/dist/recorder/index.js +1 -1
  14. package/dist/recorder/index.mjs +1 -1
  15. package/dist/session-decode/decode-worker-child.js +1 -1
  16. package/dist/session-decode/decode-worker-child.mjs +1 -1
  17. package/dist/stream-broker/_stub.js +2 -2
  18. 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
  19. 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
  20. 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
  21. package/dist/stream-broker/{hostInit-khRXMnjl.mjs → hostInit-BMCrULpc.mjs} +2 -2
  22. package/dist/stream-broker/index.js +735 -424
  23. package/dist/stream-broker/index.mjs +731 -421
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-BOXlUhWO.mjs → worker-protocol-DFH_z2-q.mjs} +1 -1
  26. package/dist/{worker-protocol-VURr0nUh.js → worker-protocol-le27Mw_8.js} +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js → MaskShapeCanvas-DI4BY7W2-C7bsz4cP.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-jFDOzPCD.js → MotionZonesSettings-NcxxQN8r-BmhLinUd.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js → PrivacyMaskSettings-APgPLF7p-BJjyGWFy.js} +1 -1
  30. package/embed-dist/assets/{index-4CtQAybX.js → index-a4CMt-Uc.js} +12 -12
  31. package/embed-dist/index.html +1 -1
  32. package/package.json +1 -1
  33. package/python/postprocessors/__init__.py +2 -0
  34. package/python/postprocessors/rfdetr.py +161 -0
  35. package/python/postprocessors/test_rfdetr.py +193 -0
  36. 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-BuFE5rOK.js");
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
- //#endregion
3193
- //#region src/stream-broker/stream-broker/fmp4-box-splitter.ts
3194
- var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
3195
- /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
3196
- var BOX_HEADER_BYTES = 8;
3197
- var LARGE_BOX_HEADER_BYTES = 16;
3198
- var Fmp4BoxSplitter = class {
3199
- maxUnitBytes;
3200
- /** Bytes of the CURRENT unit plus any partial box after it. */
3201
- buffer = new Uint8Array(0);
3202
- /** Where the current unit starts inside {@link buffer}. */
3203
- unitStart = 0;
3204
- /** Where the box scanner has reached inside {@link buffer}. */
3205
- cursor = 0;
3206
- state = "init";
3207
- nextSequence = 0;
3208
- faultReason = null;
3209
- interstitial = /* @__PURE__ */ new Set();
3210
- constructor(options = {}) {
3211
- this.maxUnitBytes = options.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES;
3212
- }
3213
- /**
3214
- * Non-null once the stream cannot be split. The splitter emits nothing
3215
- * further, so a caller polls this to kill the child rather than watching a
3216
- * silent stall a fragmenter that quietly stops producing looks exactly like
3217
- * a camera with no motion.
3218
- */
3219
- get fault() {
3220
- return this.faultReason;
3221
- }
3222
- /** Bytes currently held. The memory bound, observable rather than asserted. */
3223
- get pendingBytes() {
3224
- return this.buffer.length - this.unitStart;
3225
- }
3226
- /**
3227
- * Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
3228
- * a stray `sidx`. Reported rather than dropped in silence: they are legal and
3229
- * useless to a fragment consumer, but a type nobody expected showing up here
3230
- * is the first symptom of a muxer that is not writing what we think it is.
3231
- */
3232
- get discardedInterstitialTypes() {
3233
- return [...this.interstitial];
3234
- }
3235
- /**
3236
- * Feed bytes; get back whatever units completed. Returns `[]` once faulted.
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
- push(chunk) {
3239
- if (this.faultReason !== null || chunk.length === 0) return [];
3240
- this.append(chunk);
3241
- if (this.pendingBytes > this.maxUnitBytes) return this.fail(`a single fMP4 unit exceeded ${this.maxUnitBytes} bytes — this stream is not fragmented`);
3242
- return this.drainBoxes();
3243
- }
3244
- append(chunk) {
3245
- if (this.buffer.length === 0) {
3246
- this.buffer = chunk.slice();
3247
- return;
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
- /** Consume every COMPLETE top-level box now in the buffer. */
3255
- drainBoxes() {
3256
- const units = [];
3257
- for (;;) {
3258
- const header = this.readHeader();
3259
- if (this.faultReason !== null) return units;
3260
- if (header === null) break;
3261
- if (this.cursor + header.totalBytes > this.buffer.length) break;
3262
- const boxStart = this.cursor;
3263
- const boxEnd = boxStart + header.totalBytes;
3264
- this.cursor = boxEnd;
3265
- const unit = this.consumeBox(header.type, boxStart, boxEnd);
3266
- if (this.faultReason !== null) return units;
3267
- if (unit !== null) units.push(unit);
3268
- }
3269
- this.compact();
3270
- return units;
3271
- }
3272
- /**
3273
- * Apply one box to the state machine. Returns a unit when this box CLOSED
3274
- * one, `null` otherwise.
3275
- */
3276
- consumeBox(type, boxStart, boxEnd) {
3277
- if (this.state === "init") {
3278
- if (type !== "moof") return null;
3279
- if (boxStart === this.unitStart) {
3280
- this.fail("a moof arrived before any initialisation box — there is no ftyp/moov to send");
3281
- return null;
3282
- }
3283
- const init = this.emit("init", this.unitStart, boxStart);
3284
- this.unitStart = boxStart;
3285
- this.state = "fragment";
3286
- return init;
3287
- }
3288
- if (this.state === "idle") {
3289
- if (type !== "moof") {
3290
- this.interstitial.add(type);
3291
- this.unitStart = boxEnd;
3292
- return null;
3293
- }
3294
- this.unitStart = boxStart;
3295
- this.state = "fragment";
3296
- return null;
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
- * Parse the header at {@link cursor}, or `null` when too few bytes have
3306
- * arrived to know. Faults on a size the splitter cannot honour.
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
- readHeader() {
3309
- const available = this.buffer.length - this.cursor;
3310
- if (available < BOX_HEADER_BYTES) return null;
3311
- const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
3312
- const size = view.getUint32(this.cursor);
3313
- const type = String.fromCharCode(this.buffer[this.cursor + 4] ?? 0, this.buffer[this.cursor + 5] ?? 0, this.buffer[this.cursor + 6] ?? 0, this.buffer[this.cursor + 7] ?? 0);
3314
- if (size === 0) {
3315
- this.fail(`box "${type}" declares size 0 (to EOF) — an unbounded box cannot be fragmented`);
3316
- return null;
3317
- }
3318
- if (size === 1) {
3319
- if (available < LARGE_BOX_HEADER_BYTES) return null;
3320
- const large = view.getBigUint64(this.cursor + BOX_HEADER_BYTES);
3321
- if (large > BigInt(this.maxUnitBytes)) {
3322
- this.fail(`box "${type}" declares ${large} bytes, over the ${this.maxUnitBytes} byte bound`);
3323
- return null;
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
- return {
3335
- type,
3336
- totalBytes: size
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
- emit(kind, start, end) {
3340
- const sequence = this.nextSequence;
3341
- this.nextSequence += 1;
3484
+ facade(sub) {
3485
+ const plane = this;
3342
3486
  return {
3343
- kind,
3344
- data: this.buffer.slice(start, end),
3345
- sequence
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
- * Drop everything already emitted or discarded. Without this the buffer is
3350
- * the whole stream and the process dies in hours, not minutes.
3351
- */
3352
- compact() {
3353
- if (this.unitStart === 0) return;
3354
- this.buffer = this.buffer.slice(this.unitStart);
3355
- this.cursor -= this.unitStart;
3356
- this.unitStart = 0;
3357
- }
3358
- fail(reason) {
3359
- this.faultReason = reason;
3360
- this.buffer = new Uint8Array(0);
3361
- this.unitStart = 0;
3362
- this.cursor = 0;
3363
- return [];
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
- brokerId: this.args.brokerId,
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
- brokerId: this.args.brokerId,
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
- brokerId: this.args.brokerId,
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
- brokerId: this.args.brokerId,
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
- brokerId: this.args.brokerId,
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
- brokerId: this.args.brokerId,
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 producer stopped. Every subscriber drains what it holds; its final
3686
- * packet carries `isLast`, and its generator then completes.
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
- end(reason = "producer ended") {
3689
- if (this.ended) return;
3690
- this.ended = true;
3691
- this.logger?.info("fmp4 plane: ended", { meta: {
3692
- reason,
3693
- subscribers: this.subscriptions.size
3694
- } });
3695
- for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
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
- async *iterate(sub) {
3760
- if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
3761
- sub.iterating = true;
3762
- for (;;) {
3763
- const next = sub.queue.shift();
3764
- if (next === void 0) {
3765
- if (sub.closedReason !== null) return;
3766
- await new Promise((resolve) => {
3767
- sub.wake = resolve;
3768
- });
3769
- continue;
3770
- }
3771
- const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
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 `egress-fragment-child.ts`.
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
- brokerId: spec.brokerId,
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
- await Promise.allSettled(cameras.filter((d) => !d.disabled).map((d) => this.reconcileDeviceCatalog(d.id)));
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
  }