@camstack/addon-pipeline 1.2.44 → 1.2.46

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 (38) 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 +44 -13
  4. package/dist/detection-pipeline/index.mjs +44 -13
  5. package/dist/{dist-BuFE5rOK.js → dist-CPfFUIl9.js} +1086 -135
  6. package/dist/{dist-DOu93i_g.mjs → dist-CUj9mdhh.mjs} +1081 -136
  7. package/dist/{event-loop-stall-monitor-L08yG6bB.js → event-loop-stall-monitor-BGbgcrEN.js} +20 -7
  8. package/dist/{event-loop-stall-monitor-CShgQE6l.mjs → event-loop-stall-monitor-md__iwWh.mjs} +20 -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 +15 -6
  12. package/dist/pipeline-runner/index.mjs +15 -6
  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-aUWVCncO.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-DN2e2GJL.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-2D_L2y3g.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-khRXMnjl.mjs → hostInit-CRwWRwJH.mjs} +2 -2
  22. package/dist/stream-broker/index.js +996 -431
  23. package/dist/stream-broker/index.mjs +992 -428
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-BOXlUhWO.mjs → worker-protocol-CD0M9YD7.mjs} +1 -1
  26. package/dist/{worker-protocol-VURr0nUh.js → worker-protocol-hdTR-F5q.js} +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js → MaskShapeCanvas-DI4BY7W2-C8eGXtf-.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-jFDOzPCD.js → MotionZonesSettings-NcxxQN8r-vvEcAgxS.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js → PrivacyMaskSettings-APgPLF7p-BLhx7VI-.js} +1 -1
  30. package/embed-dist/assets/{index-4CtQAybX.js → index-DvOgpqcE.js} +12 -12
  31. package/embed-dist/assets/index-ICnBEqOy.css +2 -0
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +1 -1
  34. package/python/postprocessors/__init__.py +2 -0
  35. package/python/postprocessors/rfdetr.py +161 -0
  36. package/python/postprocessors/test_rfdetr.py +193 -0
  37. 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
  38. package/embed-dist/assets/index-DC63og2c.css +0 -2
@@ -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-CPfFUIl9.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,
@@ -2965,6 +3012,147 @@ function buildAudioCodecCapApi(api) {
2965
3012
  };
2966
3013
  }
2967
3014
  //#endregion
3015
+ //#region src/stream-broker/stream-broker/dead-decode-backends.ts
3016
+ /**
3017
+ * "This decode backend cannot create a device in THIS process" — remembered
3018
+ * across children, not per child.
3019
+ *
3020
+ * ## The thing this stops
3021
+ *
3022
+ * `-hwaccel vaapi` works for the decoder addon on this hub (ten concurrent 4K
3023
+ * decodes) and returns ENOMEM inside the stream-broker runner: `code=244`,
3024
+ * "Device creation failed: -12 … Cannot allocate memory". Every egress child
3025
+ * therefore spends ~3 s dying before its own software retry rescues it.
3026
+ *
3027
+ * That was already reported — `EgressTranscodeManager.onDecodeHwAccelFailed`
3028
+ * poisoned the broker's 60 s `egressHwAccelCache`. The memo is the wrong shape
3029
+ * for the verdict: it exists to avoid a per-session cross-process
3030
+ * `decoder.getInfo`, so it expires on a **read** cadence, and one minute later
3031
+ * the decoder answers `vaapi` again and the next cold child re-pays the death.
3032
+ * Measured on camera 615 on 2026-08-07: the hardware failure recurs at 22:17:07,
3033
+ * 22:18:42, 22:28:48, 22:30:52, 22:32:39, 22:34:07, 22:52:07 — once per memo
3034
+ * window, forever, on a hub where it has never once succeeded.
3035
+ *
3036
+ * ## Why this is a quarantine and not a tombstone
3037
+ *
3038
+ * A permanent verdict is wrong for the failure that is actually observed:
3039
+ * ENOMEM on device creation is a RESOURCE condition, and a resource condition
3040
+ * can clear (the operator gives the container more of the device, another
3041
+ * process releases it). A process that decided "never again" at 03:00 would
3042
+ * still be decoding in software at noon with a working accelerator, and nothing
3043
+ * would ever say so.
3044
+ *
3045
+ * So the verdict LASTS and escalates: each consecutive failure of the same
3046
+ * backend widens its quarantine — 30 min, 2 h, then 6 h — so a hub where the
3047
+ * backend is genuinely dead pays one probe per six hours instead of one per
3048
+ * minute, while a hub where it recovers still finds out.
3049
+ *
3050
+ * The FIRST rung is deliberately not a minute. One report is already a strong
3051
+ * signal: the manager only reports a backend after its child produced ZERO
3052
+ * packets *and* the software retry proved the same argv otherwise works, which
3053
+ * is a statement about the backend and not about the source. And the cost of
3054
+ * being wrong is bounded and already being paid — the egress encodes with
3055
+ * `libx264` either way, so an over-long quarantine costs a software DECODE on a
3056
+ * path that is decoding in software today, 100 % of the time.
3057
+ *
3058
+ * The invalidations are motivated, and there are exactly three:
3059
+ *
3060
+ * 1. **The window elapses.** The next acquire probes hardware once. If it
3061
+ * works, {@link DeadDecodeBackends.markAlive} clears the record and the
3062
+ * escalation starts from zero again.
3063
+ * 2. **The backend changes.** The record is keyed by backend name, so an
3064
+ * operator switching the decoder from vaapi to qsv is obeyed immediately —
3065
+ * qsv has no record.
3066
+ * 3. **The runner respawns.** This is instance state on the broker manager,
3067
+ * never a module global, for the same reason `HwAccelCache` is: a module
3068
+ * global outlives an addon respawn, and an addon respawn is exactly the
3069
+ * event after which a stale verdict must not survive.
3070
+ *
3071
+ * ## Why it lives here and not in `@camstack/types`
3072
+ *
3073
+ * `addon-export-hap` has the identical gap (its `failedBackend` memo forgets on
3074
+ * the same 60 s cadence) and this is deliberately NOT shared with it yet: a
3075
+ * framework package is only live once a `@camstack/server` containing it is
3076
+ * installed, whereas this ships with `camstack deploy packages/addon-pipeline`.
3077
+ * The cross-PROCESS gap D67 names — addons may not import each other and no cap
3078
+ * carries "this backend is dead on this node right now" — is unchanged either
3079
+ * way; what is closed here is the broker's own repetition of the cost.
3080
+ */
3081
+ /**
3082
+ * How long a backend stays quarantined after its 1st, 2nd, 3rd+ consecutive
3083
+ * failure. Escalating rather than fixed, and it starts at HALF AN HOUR: a
3084
+ * client watchdog cycles in seconds, so any first rung measured in minutes is
3085
+ * re-paid by the very churn this exists to stop. The last rung makes a
3086
+ * permanently-broken backend cost ~nothing while still being re-probed.
3087
+ */
3088
+ var DEAD_BACKEND_QUARANTINE_MS = [
3089
+ 18e5,
3090
+ 72e5,
3091
+ 216e5
3092
+ ];
3093
+ /**
3094
+ * The decoder's answer, overruled while the backend it names is quarantined.
3095
+ *
3096
+ * Composed rather than folded into `resolveEgressDecodeHwAccel`: that function
3097
+ * answers "what SHOULD decode this", which is a per-node property of the
3098
+ * hardware; this answers "what has been observed to work in THIS process",
3099
+ * which is a property of the runner. Keeping them apart is what stops a
3100
+ * runner-local ENOMEM from being mistaken for a node that has no accelerator —
3101
+ * the first is a flow bug to fix, the second a capability limit to accept.
3102
+ */
3103
+ async function resolveWithQuarantine(input) {
3104
+ const backend = await input.resolve();
3105
+ if (backend === null || !input.dead.isQuarantined(backend)) return {
3106
+ backend,
3107
+ veto: null
3108
+ };
3109
+ return {
3110
+ backend: null,
3111
+ veto: {
3112
+ backend,
3113
+ failures: input.dead.failuresOf(backend),
3114
+ remainingMs: input.dead.remainingMs(backend)
3115
+ }
3116
+ };
3117
+ }
3118
+ function createDeadDecodeBackends(options = {}) {
3119
+ const now = options.now ?? (() => Date.now());
3120
+ const records = /* @__PURE__ */ new Map();
3121
+ const quarantineFor = (failures) => {
3122
+ return DEAD_BACKEND_QUARANTINE_MS[Math.min(failures, DEAD_BACKEND_QUARANTINE_MS.length) - 1] ?? DEAD_BACKEND_QUARANTINE_MS[0] ?? 0;
3123
+ };
3124
+ return {
3125
+ markDead(backend) {
3126
+ const failures = (records.get(backend)?.failures ?? 0) + 1;
3127
+ const quarantineMs = quarantineFor(failures);
3128
+ records.set(backend, {
3129
+ failures,
3130
+ until: now() + quarantineMs
3131
+ });
3132
+ return {
3133
+ backend,
3134
+ failures,
3135
+ quarantineMs
3136
+ };
3137
+ },
3138
+ markAlive(backend) {
3139
+ records.delete(backend);
3140
+ },
3141
+ isQuarantined(backend) {
3142
+ const record = records.get(backend);
3143
+ return record !== void 0 && now() < record.until;
3144
+ },
3145
+ remainingMs(backend) {
3146
+ const record = records.get(backend);
3147
+ if (record === void 0) return 0;
3148
+ return Math.max(0, record.until - now());
3149
+ },
3150
+ failuresOf(backend) {
3151
+ return records.get(backend)?.failures ?? 0;
3152
+ }
3153
+ };
3154
+ }
3155
+ //#endregion
2968
3156
  //#region src/stream-broker/stream-broker/ffmpeg-invocation.ts
2969
3157
  /** The full set of hwaccel settings (for validation + option surfaces). */
2970
3158
  var FFMPEG_HW_ACCEL_SETTINGS = [
@@ -3189,182 +3377,291 @@ function pushDimension(into, constraint, requested, known) {
3189
3377
  reason: `source ${known} > requested ${requested}`
3190
3378
  });
3191
3379
  }
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.
3380
+ /**
3381
+ * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
3382
+ * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
3383
+ *
3384
+ * ## Why a plane and not a callback
3385
+ *
3386
+ * The operator's requirement for HKSV was explicit: the live fMP4 source built
3387
+ * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
3388
+ * CamStack as an additional videoclip source alongside the recorder and the NC
3389
+ * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
3390
+ * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
3391
+ * a second ffmpeg child of the same camera. So this is the same shape the
3392
+ * broker's other multi-consumer surfaces already have
3393
+ * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
3394
+ * one producer.
3395
+ *
3396
+ * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
3397
+ * clip source; both are named here so the seam is not re-invented, and neither
3398
+ * is built.
3399
+ *
3400
+ * ## The init segment is RETAINED
3401
+ *
3402
+ * A subscriber that attaches mid-stream the clip consumer joining an already
3403
+ * running HKSV session, which is the whole dual-use case receives the
3404
+ * retained `ftyp`+`moov` as its first packet and then live fragments. Without
3405
+ * retention its fragments are undecodable and the failure looks like a codec
3406
+ * problem.
3407
+ *
3408
+ * ## A slow subscriber is CLOSED, never silently gapped
3409
+ *
3410
+ * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
3411
+ * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
3412
+ * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
3413
+ * shows a clip that fails to play with nothing anywhere saying why. So a
3414
+ * subscription whose queue overflows is ENDED with a reason, loudly, and the
3415
+ * other subscriptions are untouched.
3416
+ *
3417
+ * ## The PREBUFFER (phase 3)
3418
+ *
3419
+ * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
3420
+ * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
3421
+ * motion edge has none. So the plane optionally retains the last few fragments
3422
+ * and replays them to a subscriber that asks for them.
3423
+ *
3424
+ * Three things this ring gets right, each of which is a measured fact rather
3425
+ * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
3426
+ *
3427
+ * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
3428
+ * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
3429
+ * window. A time-only bound is a per-camera RAM figure nobody can predict.
3430
+ * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
3431
+ * splitter deliberately never computes a fragment's duration (a second
3432
+ * opinion about a fact the muxer owns), and a prebuffer cares about how long
3433
+ * ago the bytes turned up, which is exactly what arrival time answers.
3434
+ * - **A replay is not backlog.** A subscriber taking N retained fragments gets
3435
+ * its queue capacity raised by N for them, because closing a subscriber as a
3436
+ * slow consumer for the prebuffer it explicitly asked for would be the
3437
+ * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
3438
+ * ring of 4, the guaranteed one.
3439
+ *
3440
+ * ## `isLast`
3441
+ *
3442
+ * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
3443
+ * `isLast` — a generator that finishes without it produces the twelve-second
3444
+ * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
3445
+ * plane therefore computes it at DELIVERY time: a packet is last when the plane
3446
+ * has ended and nothing remains queued behind it. A subscription that ends
3447
+ * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
3448
+ * the future delegate must not open an HDS stream it cannot feed.
3449
+ */
3450
+ var DEFAULT_QUEUE_CAPACITY = 4;
3451
+ var Fmp4FragmentPlane = class {
3452
+ logger;
3453
+ prebuffer;
3454
+ now;
3455
+ subscriptions = /* @__PURE__ */ new Map();
3456
+ /** The last init unit seen, handed to every later subscriber. */
3457
+ retainedInit = null;
3458
+ ended = false;
3459
+ /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
3460
+ ring = [];
3461
+ ringBytes = 0;
3462
+ constructor(logger, prebuffer, now = Date.now) {
3463
+ this.logger = logger;
3464
+ this.prebuffer = prebuffer;
3465
+ this.now = now;
3466
+ }
3467
+ get subscriberCount() {
3468
+ return this.subscriptions.size;
3469
+ }
3470
+ /** True once {@link end} has been called — no further units are accepted. */
3471
+ get isEnded() {
3472
+ return this.ended;
3473
+ }
3474
+ /** What the prebuffer ring holds right now. All zeroes when disabled. */
3475
+ prebufferStats() {
3476
+ const oldest = this.ring[0];
3477
+ return {
3478
+ fragments: this.ring.length,
3479
+ bytes: this.ringBytes,
3480
+ spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
3481
+ };
3482
+ }
3483
+ subscribe(input) {
3484
+ const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
3485
+ const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
3486
+ const sub = {
3487
+ id: `fmp4-${(0, node_crypto.randomUUID)()}`,
3488
+ tag: input.tag,
3489
+ subscribedAt: this.now(),
3490
+ capacity: requested + replay.length,
3491
+ queue: [],
3492
+ delivered: 0,
3493
+ closedReason: null,
3494
+ wake: null,
3495
+ iterating: false
3496
+ };
3497
+ this.subscriptions.set(sub.id, sub);
3498
+ if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
3499
+ for (const retained of replay) this.enqueue(sub, retained.unit);
3500
+ if (this.ended) this.closeSubscription(sub, "ended");
3501
+ this.logger?.info("fmp4 plane: subscribed", { meta: {
3502
+ subscriptionId: sub.id,
3503
+ tag: sub.tag,
3504
+ hasRetainedInit: this.retainedInit !== null,
3505
+ prebufferFragments: replay.length,
3506
+ prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
3507
+ } });
3508
+ return this.facade(sub);
3509
+ }
3510
+ /**
3511
+ * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
3512
+ * emits exactly one per child, and a second means the child was respawned, in
3513
+ * which case the old one describes a stream that no longer exists.
3237
3514
  */
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;
3515
+ publish(unit) {
3516
+ if (this.ended) return;
3517
+ if (unit.kind === "init") {
3518
+ this.retainedInit = unit;
3519
+ this.ring.length = 0;
3520
+ this.ringBytes = 0;
3521
+ } else this.retain(unit);
3522
+ for (const sub of this.subscriptions.values()) {
3523
+ if (sub.closedReason !== null) continue;
3524
+ this.enqueue(sub, unit);
3248
3525
  }
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
3526
  }
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;
3527
+ /**
3528
+ * The producer stopped. Every subscriber drains what it holds; its final
3529
+ * packet carries `isLast`, and its generator then completes.
3530
+ */
3531
+ end(reason = "producer ended") {
3532
+ if (this.ended) return;
3533
+ this.ended = true;
3534
+ this.logger?.info("fmp4 plane: ended", { meta: {
3535
+ reason,
3536
+ subscribers: this.subscriptions.size
3537
+ } });
3538
+ for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
3539
+ }
3540
+ listSubscribers() {
3541
+ return [...this.subscriptions.values()].map((s) => ({
3542
+ tag: s.tag,
3543
+ subscribedAt: s.subscribedAt,
3544
+ delivered: s.delivered,
3545
+ closedReason: s.closedReason
3546
+ }));
3547
+ }
3548
+ /** End and forget everything. Idempotent. */
3549
+ dispose() {
3550
+ this.end("disposed");
3551
+ this.subscriptions.clear();
3552
+ this.retainedInit = null;
3553
+ this.ring.length = 0;
3554
+ this.ringBytes = 0;
3555
+ }
3556
+ /**
3557
+ * Add one fragment to the ring and evict from the front until BOTH bounds
3558
+ * hold. Eviction is oldest-first, which is the one place in this file where
3559
+ * dropping is correct: the ring is context, not stream — nobody is mid-decode
3560
+ * on it, and a subscriber only ever receives a contiguous tail of it.
3561
+ */
3562
+ retain(unit) {
3563
+ const prebuffer = this.prebuffer;
3564
+ if (prebuffer === void 0) return;
3565
+ const arrivedAt = this.now();
3566
+ this.ring.push({
3567
+ unit,
3568
+ arrivedAt
3569
+ });
3570
+ this.ringBytes += unit.data.length;
3571
+ const cutoff = arrivedAt - prebuffer.windowMs;
3572
+ while (this.ring.length > 0) {
3573
+ const oldest = this.ring[0];
3574
+ if (oldest === void 0) break;
3575
+ const tooOld = oldest.arrivedAt < cutoff;
3576
+ const tooBig = this.ringBytes > prebuffer.maxBytes;
3577
+ if (!tooOld && !tooBig || this.ring.length === 1) break;
3578
+ this.ring.shift();
3579
+ this.ringBytes -= oldest.unit.data.length;
3297
3580
  }
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
3581
  }
3304
3582
  /**
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.
3583
+ * The ring as a subscriber should receive it window applied AT SUBSCRIBE
3584
+ * time, not only at publish time. A camera that went quiet keeps its last
3585
+ * fragment in the ring indefinitely (see the never-evict-the-newest rule),
3586
+ * and replaying a 40-second-old fragment as "prebuffer" would put stale video
3587
+ * at the head of a clip iOS presents as the moment of the event.
3307
3588
  */
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;
3589
+ trimmedRing() {
3590
+ const prebuffer = this.prebuffer;
3591
+ if (prebuffer === void 0) return [];
3592
+ const cutoff = this.now() - prebuffer.windowMs;
3593
+ return this.ring.filter((r) => r.arrivedAt >= cutoff);
3594
+ }
3595
+ enqueue(sub, unit) {
3596
+ if (sub.queue.length >= sub.capacity) {
3597
+ this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
3598
+ subscriptionId: sub.id,
3599
+ tag: sub.tag,
3600
+ capacity: sub.capacity,
3601
+ delivered: sub.delivered
3602
+ } });
3603
+ this.closeSubscription(sub, "slow-consumer");
3604
+ return;
3333
3605
  }
3334
- return {
3335
- type,
3336
- totalBytes: size
3337
- };
3606
+ sub.queue.push({
3607
+ kind: unit.kind,
3608
+ data: unit.data,
3609
+ sequence: unit.sequence,
3610
+ isLast: false
3611
+ });
3612
+ this.wake(sub);
3338
3613
  }
3339
- emit(kind, start, end) {
3340
- const sequence = this.nextSequence;
3341
- this.nextSequence += 1;
3614
+ closeSubscription(sub, reason) {
3615
+ if (sub.closedReason !== null) return;
3616
+ sub.closedReason = reason;
3617
+ if (reason === "slow-consumer") sub.queue.length = 0;
3618
+ this.wake(sub);
3619
+ }
3620
+ wake(sub) {
3621
+ const resume = sub.wake;
3622
+ sub.wake = null;
3623
+ resume?.();
3624
+ }
3625
+ facade(sub) {
3626
+ const plane = this;
3342
3627
  return {
3343
- kind,
3344
- data: this.buffer.slice(start, end),
3345
- sequence
3628
+ id: sub.id,
3629
+ tag: sub.tag,
3630
+ get delivered() {
3631
+ return sub.delivered;
3632
+ },
3633
+ get closedReason() {
3634
+ return sub.closedReason;
3635
+ },
3636
+ packets: () => plane.iterate(sub),
3637
+ release: () => {
3638
+ plane.closeSubscription(sub, "released");
3639
+ plane.subscriptions.delete(sub.id);
3640
+ }
3346
3641
  };
3347
3642
  }
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 [];
3643
+ async *iterate(sub) {
3644
+ if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
3645
+ sub.iterating = true;
3646
+ for (;;) {
3647
+ const next = sub.queue.shift();
3648
+ if (next === void 0) {
3649
+ if (sub.closedReason !== null) return;
3650
+ await new Promise((resolve) => {
3651
+ sub.wake = resolve;
3652
+ });
3653
+ continue;
3654
+ }
3655
+ const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
3656
+ sub.delivered += 1;
3657
+ yield {
3658
+ ...next,
3659
+ isLast
3660
+ };
3661
+ if (isLast) return;
3662
+ }
3364
3663
  }
3365
3664
  };
3366
- //#endregion
3367
- //#region src/stream-broker/stream-broker/egress-fragment-child.ts
3368
3665
  var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
3369
3666
  /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
3370
3667
  var FRAGMENT_LOG_EVERY = 30;
@@ -3372,7 +3669,7 @@ var Fmp4FragmentChild = class {
3372
3669
  deps;
3373
3670
  args;
3374
3671
  child = null;
3375
- splitter = new Fmp4BoxSplitter();
3672
+ splitter = new require_dist.Fmp4BoxSplitter();
3376
3673
  stopped = false;
3377
3674
  unitsOut = 0;
3378
3675
  activeHwAccel = null;
@@ -3393,13 +3690,13 @@ var Fmp4FragmentChild = class {
3393
3690
  this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
3394
3691
  tags: { deviceId: this.args.deviceId },
3395
3692
  meta: {
3396
- brokerId: this.args.brokerId,
3693
+ sourceId: this.args.sourceId,
3397
3694
  decodeHwAccel: requested,
3398
3695
  error: require_dist.errMsg(err)
3399
3696
  }
3400
3697
  });
3401
3698
  this.killChild();
3402
- this.splitter = new Fmp4BoxSplitter();
3699
+ this.splitter = new require_dist.Fmp4BoxSplitter();
3403
3700
  this.activeHwAccel = null;
3404
3701
  await this.spawnAttempt(null);
3405
3702
  }
@@ -3429,7 +3726,7 @@ var Fmp4FragmentChild = class {
3429
3726
  this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
3430
3727
  tags: { deviceId: this.args.deviceId },
3431
3728
  meta: {
3432
- brokerId: this.args.brokerId,
3729
+ sourceId: this.args.sourceId,
3433
3730
  fragmentMs: this.args.fragmentMs,
3434
3731
  decodeHwAccel: decodeHwAccel ?? "software",
3435
3732
  argv: args.join(" ")
@@ -3483,7 +3780,7 @@ var Fmp4FragmentChild = class {
3483
3780
  this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
3484
3781
  tags: { deviceId: this.args.deviceId },
3485
3782
  meta: {
3486
- brokerId: this.args.brokerId,
3783
+ sourceId: this.args.sourceId,
3487
3784
  bytes: unit.data.length
3488
3785
  }
3489
3786
  });
@@ -3491,7 +3788,7 @@ var Fmp4FragmentChild = class {
3491
3788
  } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
3492
3789
  tags: { deviceId: this.args.deviceId },
3493
3790
  meta: {
3494
- brokerId: this.args.brokerId,
3791
+ sourceId: this.args.sourceId,
3495
3792
  unitsOut: this.unitsOut,
3496
3793
  bytes: unit.data.length,
3497
3794
  subscribers: this.args.plane.subscriberCount
@@ -3506,7 +3803,7 @@ var Fmp4FragmentChild = class {
3506
3803
  this.deps.logger.debug("fmp4 fragment child ffmpeg", {
3507
3804
  tags: { deviceId: this.args.deviceId },
3508
3805
  meta: {
3509
- brokerId: this.args.brokerId,
3806
+ sourceId: this.args.sourceId,
3510
3807
  line: line.trim()
3511
3808
  }
3512
3809
  });
@@ -3522,259 +3819,61 @@ var Fmp4FragmentChild = class {
3522
3819
  });
3523
3820
  child.once("exit", (code, signal) => {
3524
3821
  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
- }
3683
- }
3684
- /**
3685
- * The producer stopped. Every subscriber drains what it holds; its final
3686
- * packet carries `isLast`, and its generator then completes.
3687
- */
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
3822
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
3823
+ return;
3824
+ }
3825
+ if (!isCurrent()) return;
3826
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
3827
+ this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
3828
+ tags: { deviceId: this.args.deviceId },
3829
+ meta: {
3830
+ sourceId: this.args.sourceId,
3831
+ code,
3832
+ signal,
3833
+ unitsOut: this.unitsOut
3834
+ }
3835
+ });
3836
+ this.args.plane.end("the fragment child exited");
3837
+ this.deps.onChildExit?.(error);
3838
+ });
3727
3839
  });
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
3840
  }
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);
3841
+ /**
3842
+ * The byte stream stopped being splittable. Not recoverable — the splitter
3843
+ * cannot resynchronise mid-box — so the child is a corpse and every consumer
3844
+ * has to be told, loudly, with the reason.
3845
+ */
3846
+ onFault(reason, wasLive, current, settle) {
3847
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
3848
+ this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
3849
+ tags: { deviceId: this.args.deviceId },
3850
+ meta: {
3851
+ sourceId: this.args.sourceId,
3852
+ unitsOut: this.unitsOut,
3853
+ interstitial: this.splitter.discardedInterstitialTypes,
3854
+ reason
3756
3855
  }
3757
- };
3856
+ });
3857
+ this.killChild();
3858
+ settle(error);
3859
+ if (wasLive && current) {
3860
+ this.args.plane.end("the fragment child produced unsplittable output");
3861
+ this.deps.onChildExit?.(error);
3862
+ }
3758
3863
  }
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;
3864
+ killChild() {
3865
+ const child = this.child;
3866
+ this.child = null;
3867
+ if (child && !child.killed) try {
3868
+ child.kill("SIGTERM");
3869
+ } catch (err) {
3870
+ this.deps.logger.warn("fmp4 fragment child: kill error", {
3871
+ tags: { deviceId: this.args.deviceId },
3872
+ meta: {
3873
+ sourceId: this.args.sourceId,
3874
+ error: require_dist.errMsg(err)
3875
+ }
3876
+ });
3778
3877
  }
3779
3878
  }
3780
3879
  };
@@ -4402,6 +4501,7 @@ var EgressTranscodeManager = class {
4402
4501
  }
4403
4502
  const actualHwAccel = child.decodeHwAccel();
4404
4503
  if (decodeHwAccel !== null && !require_dist.isSoftwareDecode(decodeHwAccel) && actualHwAccel === null) this.opts.onDecodeHwAccelFailed?.(decodeHwAccel);
4504
+ else if (actualHwAccel !== null && !require_dist.isSoftwareDecode(actualHwAccel)) this.opts.onDecodeHwAccelSucceeded?.(actualHwAccel);
4405
4505
  const entry = {
4406
4506
  key,
4407
4507
  deviceId,
@@ -4604,7 +4704,7 @@ var EgressTranscodeManager = class {
4604
4704
  *
4605
4705
  * The child owns the software retry and the liveness reporting itself, in the
4606
4706
  * same shape as the other two transports; this wrapper is the adapter, not
4607
- * the logic. See `egress-fragment-child.ts`.
4707
+ * the logic. See the primitive's `ffmpeg/fmp4-fragment-child.ts`.
4608
4708
  */
4609
4709
  createFragmentChild(spec, fragments) {
4610
4710
  const child = new Fmp4FragmentChild({
@@ -4614,7 +4714,7 @@ var EgressTranscodeManager = class {
4614
4714
  ...this.opts.fragmentFirstUnitTimeoutMs === void 0 ? {} : { firstUnitTimeoutMs: this.opts.fragmentFirstUnitTimeoutMs },
4615
4715
  ...spec.onDied ? { onChildExit: spec.onDied } : {}
4616
4716
  }, {
4617
- brokerId: spec.brokerId,
4717
+ sourceId: spec.brokerId,
4618
4718
  deviceId: spec.deviceId,
4619
4719
  fragmentMs: fragments.fragmentMs,
4620
4720
  invocation: spec.invocation,
@@ -9863,6 +9963,29 @@ var StreamBroker = class StreamBroker {
9863
9963
  * right model based on per-camera codec + sample rate.
9864
9964
  */
9865
9965
  audioTrackInfo = null;
9966
+ /**
9967
+ * BROKER-SIDE AUDIO MUTE (D83) — operator policy, not a stream fact.
9968
+ *
9969
+ * While true this broker distributes NO audio on any plane it serves. It is
9970
+ * applied at the three points audio ENTERS the broker (`onAudioRtp`,
9971
+ * `pushEncodedPacket`) and re-checked at the single point it LEAVES
9972
+ * (`fanOutEncoded`), so a packet already inside a decode session when the
9973
+ * mute lands cannot escape.
9974
+ *
9975
+ * Deliberately NOT cleared by `resetStreamState` / a re-dial: it is device
9976
+ * policy that outlives any one connection. `StreamBrokerManager` re-applies
9977
+ * it to every freshly created broker for the same reason.
9978
+ */
9979
+ audioMuted = false;
9980
+ /** Audio units dropped by the mute since the current dial. Surfaced in stats. */
9981
+ audioMutedDropped = 0;
9982
+ /**
9983
+ * One-shot per dial: the mute's drop is announced the FIRST time it actually
9984
+ * drops something, not once per packet. A broker created while already muted
9985
+ * has no transition to log, and a silent drop reads as "the camera has no
9986
+ * microphone" — which is the confusion this whole row exists to remove.
9987
+ */
9988
+ audioMutedDropLogged = false;
9866
9989
  reconnectTimer;
9867
9990
  placeholderTimer;
9868
9991
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
@@ -10414,6 +10537,69 @@ var StreamBroker = class StreamBroker {
10414
10537
  this.logger?.warn("source-restart observer threw", { meta: { error: require_dist.errMsg(err) } });
10415
10538
  }
10416
10539
  }
10540
+ /**
10541
+ * Turn this broker's audio plane off (or back on).
10542
+ *
10543
+ * Idempotent: setting the value it already holds does nothing at all — in
10544
+ * particular it does NOT churn the RTSP sessions, so the manager can call
10545
+ * this on every broker creation and every reconcile pass without cost.
10546
+ *
10547
+ * A real transition closes the restreamer's audio-bearing pull sessions.
10548
+ * That is not incidental: the recorder pulls this restreamer with `-map 0`,
10549
+ * so its ffmpeg maps whatever the SDP declared at CONNECT time. Dropping the
10550
+ * RTP while leaving an `m=audio` in its SDP would leave it waiting on an
10551
+ * audio stream that never delivers (the mp4 muxer holds video up to
10552
+ * `max_interleave_delta`). Re-pulling is what makes the recording honestly
10553
+ * audio-less rather than audio-declared-and-starved. The recorder's
10554
+ * `SegmentWriter` respawns on exit with bounded backoff, so the cost is one
10555
+ * segment boundary per flip.
10556
+ */
10557
+ setAudioMuted(muted) {
10558
+ if (this.audioMuted === muted) return;
10559
+ this.audioMuted = muted;
10560
+ this.audioMutedDropped = 0;
10561
+ this.audioMutedDropLogged = false;
10562
+ const closedPulls = this.rtspRestreamer.setAudioMuted(muted);
10563
+ this.logger?.info(muted ? "audio plane MUTED broker-side — live and recording go silent" : "audio plane UNMUTED broker-side — audio distribution resumes", {
10564
+ tags: { deviceId: this.numericDeviceId },
10565
+ meta: {
10566
+ brokerId: this.deviceId,
10567
+ audioCodec: this.audioCodec,
10568
+ hasAudioTrack: this.audioTrackInfo !== null,
10569
+ closedPulls
10570
+ }
10571
+ });
10572
+ }
10573
+ /** The broker's own audio-plane policy. `true` = distributing no audio. */
10574
+ isAudioMuted() {
10575
+ return this.audioMuted;
10576
+ }
10577
+ /**
10578
+ * True when the current connection is muxing audio at all — the OBSERVED
10579
+ * half of the D83 audio-plane reconcile. Read off the SDP/track snapshot
10580
+ * rather than a packet counter so a camera that negotiated an audio track
10581
+ * and has simply been quiet does not read as a missing mux.
10582
+ */
10583
+ hasAudioTrack() {
10584
+ return this.audioTrackInfo !== null;
10585
+ }
10586
+ /**
10587
+ * Account for one audio unit the mute threw away, announcing the FIRST one
10588
+ * per dial. Never per-packet: at 50 pps a line each would be its own outage.
10589
+ */
10590
+ dropMutedAudio(where) {
10591
+ this.audioMutedDropped += 1;
10592
+ if (this.audioMutedDropLogged) return;
10593
+ this.audioMutedDropLogged = true;
10594
+ this.logger?.info("audio muted broker-side — dropping this camera’s audio plane", {
10595
+ tags: { deviceId: this.numericDeviceId },
10596
+ meta: {
10597
+ brokerId: this.deviceId,
10598
+ where,
10599
+ audioCodec: this.audioCodec
10600
+ }
10601
+ });
10602
+ }
10417
10603
  /** Set whether pre-buffer counts as demand. When false and no other consumers, the broker suspends. */
10418
10604
  setPreBufferEnabled(enabled) {
10419
10605
  this._preBufferEnabled = enabled;
@@ -10686,6 +10872,10 @@ var StreamBroker = class StreamBroker {
10686
10872
  }
10687
10873
  this.resetPushStallTimer();
10688
10874
  }
10875
+ if (packet.type === "audio" && this.audioMuted) {
10876
+ this.dropMutedAudio("push-encoded");
10877
+ return;
10878
+ }
10689
10879
  if (packet.type === "audio" && this.source?.type === "push" && this.audioCodecSession) {
10690
10880
  const codec = packet.codec.toLowerCase();
10691
10881
  if (codec !== "pcmu" && codec !== "pcma") {
@@ -10966,6 +11156,10 @@ var StreamBroker = class StreamBroker {
10966
11156
  * safe one.
10967
11157
  */
10968
11158
  fanOutEncoded(packet) {
11159
+ if (packet.type === "audio" && this.audioMuted) {
11160
+ this.dropMutedAudio("encoded-fanout");
11161
+ return;
11162
+ }
10969
11163
  for (const [cb, entry] of this.encodedCallbacks) try {
10970
11164
  cb(packet);
10971
11165
  entry.packetsDelivered += 1;
@@ -11244,7 +11438,9 @@ var StreamBroker = class StreamBroker {
11244
11438
  preBufferMs: this.preBuffer.getBufferedDurationMs(),
11245
11439
  preBufferPackets: this.preBuffer.getPacketCount(),
11246
11440
  decoderNodeId: this.frameHandlePlane?.decoderNodeIds()[0] ?? null,
11247
- audio: this.audioTrackInfo ?? null
11441
+ audio: this.audioTrackInfo ?? null,
11442
+ audioMuted: this.audioMuted,
11443
+ audioMutedDropped: this.audioMutedDropped
11248
11444
  };
11249
11445
  }
11250
11446
  /**
@@ -11679,6 +11875,10 @@ var StreamBroker = class StreamBroker {
11679
11875
  onAudioRtp: (rtpData) => {
11680
11876
  if (this.stopping) return;
11681
11877
  this.kickRtpActivityWatchdog();
11878
+ if (this.audioMuted) {
11879
+ this.dropMutedAudio("rtsp-audio-rtp");
11880
+ return;
11881
+ }
11682
11882
  this.audioRtpSeen++;
11683
11883
  if (this.audioRtpSeen === 1 || this.audioRtpSeen === 50) this.logger?.info("audio: rtp packet received", { meta: {
11684
11884
  seq: this.audioRtpSeen,
@@ -12341,6 +12541,79 @@ function hexToBytes(hex) {
12341
12541
  }
12342
12542
  return out;
12343
12543
  }
12544
+ /**
12545
+ * Minimum gap between two re-dials of the same device. A re-dial costs a
12546
+ * ~2 s video interruption on every source of the camera; at the 30 s
12547
+ * reconcile cadence, an un-cooled loop against a camera whose audio genuinely
12548
+ * cannot come back would be a permanent stutter.
12549
+ */
12550
+ var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
12551
+ var EMPTY_AUDIO_PLANE_STATE = {
12552
+ disagreements: 0,
12553
+ redials: 0,
12554
+ lastRedialMs: 0,
12555
+ gaveUp: false
12556
+ };
12557
+ /**
12558
+ * Decide what one pass should do. Pure: no clock, no I/O, no broker.
12559
+ *
12560
+ * `nowMs` is passed in rather than read so the cooldown is testable without
12561
+ * waiting five minutes for it.
12562
+ */
12563
+ function decideAudioPlaneAction(observation, state, nowMs) {
12564
+ switch (observation.kind) {
12565
+ case "broker-muted":
12566
+ case "no-live-source":
12567
+ case "audio-present":
12568
+ case "declared-off": return {
12569
+ action: { kind: "none" },
12570
+ nextState: EMPTY_AUDIO_PLANE_STATE
12571
+ };
12572
+ case "read-failed":
12573
+ case "audio-unknown": return {
12574
+ action: { kind: "none" },
12575
+ nextState: state
12576
+ };
12577
+ case "declared-on": {
12578
+ if (state.gaveUp) return {
12579
+ action: { kind: "none" },
12580
+ nextState: state
12581
+ };
12582
+ const disagreements = state.disagreements + 1;
12583
+ if (disagreements < 2) return {
12584
+ action: { kind: "await-second-pass" },
12585
+ nextState: {
12586
+ ...state,
12587
+ disagreements
12588
+ }
12589
+ };
12590
+ if (state.redials >= 3) return {
12591
+ action: { kind: "give-up" },
12592
+ nextState: {
12593
+ ...state,
12594
+ disagreements,
12595
+ gaveUp: true
12596
+ }
12597
+ };
12598
+ if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
12599
+ action: { kind: "cooldown" },
12600
+ nextState: {
12601
+ ...state,
12602
+ disagreements: 2
12603
+ }
12604
+ };
12605
+ return {
12606
+ action: { kind: "redial" },
12607
+ nextState: {
12608
+ disagreements: 0,
12609
+ redials: state.redials + 1,
12610
+ lastRedialMs: nowMs,
12611
+ gaveUp: false
12612
+ }
12613
+ };
12614
+ }
12615
+ }
12616
+ }
12344
12617
  //#endregion
12345
12618
  //#region src/stream-broker/stream-broker/transcode-pipeline.ts
12346
12619
  /**
@@ -12825,12 +13098,31 @@ var DeviceOverrideSchema = require_dist.object({
12825
13098
  preBuffer: require_dist.record(require_dist.string(), StreamPreBufferSchema).optional(),
12826
13099
  joinBurstMaxSpanMs: require_dist.record(require_dist.string(), require_dist.number()).optional(),
12827
13100
  streamingDebug: require_dist.boolean().optional(),
13101
+ audioMuted: require_dist.boolean().optional(),
12828
13102
  webrtcDebug: require_dist.boolean().optional(),
12829
13103
  derivedStreams: require_dist.record(require_dist.string(), DerivedStreamSchema).optional(),
12830
13104
  hwaccel: require_dist.record(require_dist.string(), FfmpegHwAccelSettingSchema).optional(),
12831
13105
  sourceParser: require_dist.record(require_dist.string(), SourceParserSettingSchema).optional(),
12832
13106
  sourceParserArgs: require_dist.record(require_dist.string(), SourceParserArgsEntrySchema).optional()
12833
13107
  });
13108
+ /**
13109
+ * Read `audioEnabled` off a `privacyMask.getStatus` payload (D83 reconcile).
13110
+ *
13111
+ * `getStatus` is the one cap method whose output is the generic device-status
13112
+ * shape rather than the cap's own schema, so it arrives untyped — this is the
13113
+ * documented type boundary, and a guard rather than a cast.
13114
+ *
13115
+ * `null` means UNKNOWN, and unknown must never become "audio is on": that is
13116
+ * the read that would re-dial a healthy stream on the strength of a payload
13117
+ * we could not parse. The camera says `null` itself when it has no microphone
13118
+ * control (`supportsAudioMute === false`), which lands in the same bucket for
13119
+ * the same reason.
13120
+ */
13121
+ function readDeclaredAudioEnabled(v) {
13122
+ if (v === null || typeof v !== "object") return null;
13123
+ const raw = { ...v }["audioEnabled"];
13124
+ return typeof raw === "boolean" ? raw : null;
13125
+ }
12834
13126
  /** Whole-blob schema for the persisted `deviceOverrides` store key:
12835
13127
  * numeric-string deviceId → DeviceOverride (includes `derivedStreams`). */
12836
13128
  var DeviceOverridesMapSchema = require_dist.record(require_dist.string(), DeviceOverrideSchema);
@@ -12954,6 +13246,11 @@ var StreamBrokerManager = class StreamBrokerManager {
12954
13246
  /** Per-device overrides (pre-buffer + legacy). */
12955
13247
  deviceOverrides = /* @__PURE__ */ new Map();
12956
13248
  /**
13249
+ * D83 audio-plane reconcile memory, per device. Absent = agreeing; entries
13250
+ * are deleted the moment a pass agrees, so a healthy fleet holds none.
13251
+ */
13252
+ audioPlaneState = /* @__PURE__ */ new Map();
13253
+ /**
12957
13254
  * `deviceId:camStreamId` keys for which the `native-udp` → `native-tcp`
12958
13255
  * fallback warning has already been logged, so the warn fires once per stream
12959
13256
  * rather than on every source (re)build / dial.
@@ -13161,6 +13458,15 @@ var StreamBrokerManager = class StreamBrokerManager {
13161
13458
  */
13162
13459
  egressHwAccelCache = require_dist.createHwAccelCache({ ttlMs: 6e4 });
13163
13460
  /**
13461
+ * Backends that could not create a device in THIS process. Separate from the
13462
+ * memo above on purpose: the memo answers "what should decode this" on a read
13463
+ * cadence and expires in a minute, so folding the failure into it meant the
13464
+ * next cold child one minute later re-paid the same ~3 s death — measured
13465
+ * seven times on 615 between 22:17 and 22:52 on 2026-08-07. See
13466
+ * `dead-decode-backends.ts`.
13467
+ */
13468
+ deadDecodeBackends = createDeadDecodeBackends();
13469
+ /**
13164
13470
  * Snapshot of the system `ffmpeg` config section (binaryPath / hwAccel /
13165
13471
  * threadCount). Refreshed by the host addon via `setFfmpegConfig`; the
13166
13472
  * transcode manager reads it live per spawn. Defaults are safe (PATH ffmpeg,
@@ -13360,8 +13666,17 @@ var StreamBrokerManager = class StreamBrokerManager {
13360
13666
  };
13361
13667
  },
13362
13668
  onDecodeHwAccelFailed: (backend) => {
13363
- this.egressHwAccelCache.write(null);
13364
- this.logger.warn("egress transcode: decode backend FAILED in this process — software until the memo expires", { meta: { backend } });
13669
+ const verdict = this.deadDecodeBackends.markDead(backend);
13670
+ this.logger.warn("egress transcode: decode backend FAILED in this process — QUARANTINED, software until it is re-probed", { meta: {
13671
+ backend,
13672
+ failures: verdict.failures,
13673
+ quarantineMs: verdict.quarantineMs
13674
+ } });
13675
+ },
13676
+ onDecodeHwAccelSucceeded: (backend) => {
13677
+ if (this.deadDecodeBackends.failuresOf(backend) === 0) return;
13678
+ this.deadDecodeBackends.markAlive(backend);
13679
+ this.logger.info("egress transcode: decode backend RECOVERED in this process — the quarantine is lifted", { meta: { backend } });
13365
13680
  }
13366
13681
  });
13367
13682
  }
@@ -13384,6 +13699,22 @@ var StreamBrokerManager = class StreamBrokerManager {
13384
13699
  * hub).
13385
13700
  */
13386
13701
  async resolveEgressHwAccel(deviceId, override) {
13702
+ const resolved = await resolveWithQuarantine({
13703
+ resolve: () => this.readEgressHwAccel(deviceId, override),
13704
+ dead: this.deadDecodeBackends
13705
+ });
13706
+ if (resolved.veto !== null) this.logger.warn("egress transcode: decoding in SOFTWARE — the backend is QUARANTINED after failing in this process", {
13707
+ tags: { deviceId },
13708
+ meta: {
13709
+ backend: resolved.veto.backend,
13710
+ failures: resolved.veto.failures,
13711
+ remainingMs: resolved.veto.remainingMs
13712
+ }
13713
+ });
13714
+ return resolved.backend;
13715
+ }
13716
+ /** The decoder addon's answer for this node — before the process quarantine. */
13717
+ async readEgressHwAccel(deviceId, override) {
13387
13718
  const decoderApi = this.buildDecoderCapApi(deviceId);
13388
13719
  return require_dist.resolveEgressDecodeHwAccel({
13389
13720
  readDecoderBackend: async () => {
@@ -14301,7 +14632,96 @@ var StreamBrokerManager = class StreamBrokerManager {
14301
14632
  this.disabledDevices.add(d.id);
14302
14633
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
14303
14634
  } else this.disabledDevices.delete(d.id);
14304
- await Promise.allSettled(cameras.filter((d) => !d.disabled).map((d) => this.reconcileDeviceCatalog(d.id)));
14635
+ const live = cameras.filter((d) => !d.disabled);
14636
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id)));
14637
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
14638
+ const liveIds = new Set(live.map((d) => d.id));
14639
+ for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
14640
+ }
14641
+ /**
14642
+ * ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
14643
+ * for the decision rules; this half only gathers and acts.
14644
+ *
14645
+ * Every branch that changes nothing still says so at debug — the whole
14646
+ * failure this repairs was invisible, and a reconcile whose "I looked and
14647
+ * everything was fine" is unobservable is a reconcile nobody can trust
14648
+ * after the next incident.
14649
+ */
14650
+ async reconcileDeviceAudioPlane(deviceId) {
14651
+ const observation = await this.observeAudioPlane(deviceId);
14652
+ const { action, nextState } = decideAudioPlaneAction(observation, this.audioPlaneState.get(deviceId) ?? EMPTY_AUDIO_PLANE_STATE, Date.now());
14653
+ if (nextState === EMPTY_AUDIO_PLANE_STATE) this.audioPlaneState.delete(deviceId);
14654
+ else this.audioPlaneState.set(deviceId, nextState);
14655
+ const meta = {
14656
+ observed: observation.kind,
14657
+ disagreements: nextState.disagreements,
14658
+ redials: nextState.redials
14659
+ };
14660
+ switch (action.kind) {
14661
+ case "none": return;
14662
+ case "await-second-pass":
14663
+ this.logger.info("audio-plane reconcile: camera declares audio but no broker carries a track — confirming on the next pass", {
14664
+ tags: { deviceId },
14665
+ meta
14666
+ });
14667
+ return;
14668
+ case "cooldown":
14669
+ this.logger.debug("audio-plane reconcile: disagreement held — re-dial cooling down", {
14670
+ tags: { deviceId },
14671
+ meta: {
14672
+ ...meta,
14673
+ cooldownMs: AUDIO_PLANE_REDIAL_COOLDOWN_MS
14674
+ }
14675
+ });
14676
+ return;
14677
+ case "give-up":
14678
+ this.logger.warn("audio-plane reconcile: giving up — the camera declares audio and re-dialing has not brought a track back", {
14679
+ tags: { deviceId },
14680
+ meta: {
14681
+ ...meta,
14682
+ maxRedials: 3
14683
+ }
14684
+ });
14685
+ return;
14686
+ case "redial":
14687
+ this.logger.info("audio-plane reconcile: audio declared but absent on every source — re-dialing", {
14688
+ tags: { deviceId },
14689
+ meta
14690
+ });
14691
+ this.redialDeviceSources(deviceId, "audio-plane-reconcile");
14692
+ return;
14693
+ }
14694
+ }
14695
+ /**
14696
+ * The gather half. Cheap questions first: the expensive device read happens
14697
+ * ONLY when the free in-memory answer already looks wrong.
14698
+ */
14699
+ async observeAudioPlane(deviceId) {
14700
+ if (this.deviceOverrides.get(deviceId)?.audioMuted === true) return { kind: "broker-muted" };
14701
+ const streams = this.cameraStreams.get(deviceId);
14702
+ if (!streams) return { kind: "no-live-source" };
14703
+ const sources = [];
14704
+ for (const [camStreamId, entry] of streams) {
14705
+ if (entry.kind === "derived") continue;
14706
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
14707
+ if (broker && broker.status === "streaming") sources.push(broker);
14708
+ }
14709
+ if (sources.length === 0) return { kind: "no-live-source" };
14710
+ if (sources.some((b) => b.hasAudioTrack())) return { kind: "audio-present" };
14711
+ if (!this.api) return { kind: "read-failed" };
14712
+ let status;
14713
+ try {
14714
+ status = await this.api.privacyMask.getStatus.query({ deviceId });
14715
+ } catch (err) {
14716
+ this.logger.debug("audio-plane reconcile: device audio read failed — changing nothing", {
14717
+ tags: { deviceId },
14718
+ meta: { error: require_dist.errMsg(err) }
14719
+ });
14720
+ return { kind: "read-failed" };
14721
+ }
14722
+ const declared = readDeclaredAudioEnabled(status);
14723
+ if (declared === null) return { kind: "audio-unknown" };
14724
+ return declared ? { kind: "declared-on" } : { kind: "declared-off" };
14305
14725
  }
14306
14726
  /** Release every live broker of a disabled device (idempotent — later poll
14307
14727
  * ticks find none and no-op). `logTransition` marks the disable EDGE. */
@@ -16260,6 +16680,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16260
16680
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
16261
16681
  broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
16262
16682
  this.applyPreBufferConfig(broker, deviceId, camStreamId);
16683
+ this.applyAudioMuteConfig(broker, deviceId);
16263
16684
  this.applyJoinBurstBound(broker, deviceId, camStreamId);
16264
16685
  this.applyClipRetention(deviceId);
16265
16686
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
@@ -16455,6 +16876,72 @@ var StreamBrokerManager = class StreamBrokerManager {
16455
16876
  broker.setPreBufferEnabled(defaultWarm);
16456
16877
  broker.setPreBufferDuration(defaultWarm ? this.defaultPreBufferSec : 0);
16457
16878
  }
16879
+ /** Push the persisted mute onto a broker. Idempotent — safe on every pass. */
16880
+ applyAudioMuteConfig(broker, deviceId) {
16881
+ broker.setAudioMuted(this.deviceOverrides.get(deviceId)?.audioMuted === true);
16882
+ }
16883
+ /** Every live broker of a device, derived and source alike. */
16884
+ brokersOfDevice(deviceId) {
16885
+ const streams = this.cameraStreams.get(deviceId);
16886
+ if (!streams) return [];
16887
+ const found = [];
16888
+ for (const camStreamId of streams.keys()) {
16889
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
16890
+ if (broker) found.push(broker);
16891
+ }
16892
+ return found;
16893
+ }
16894
+ /**
16895
+ * `streamBroker.getDeviceAudioMute` — read the device's audio-plane policy.
16896
+ *
16897
+ * The STORE is the answer, not the brokers: the policy applies to brokers
16898
+ * that do not exist yet, so a camera with nothing dialled is still muted.
16899
+ * `appliedBrokers` reports how much of it is presently in force, and is
16900
+ * diagnostic only — a switch that read it as the state would flip itself
16901
+ * off every time the camera went idle.
16902
+ */
16903
+ getDeviceAudioMute(input) {
16904
+ return {
16905
+ muted: this.deviceOverrides.get(input.deviceId)?.audioMuted === true,
16906
+ appliedBrokers: this.brokersOfDevice(input.deviceId).filter((b) => b.isAudioMuted()).length
16907
+ };
16908
+ }
16909
+ /**
16910
+ * Set the device's audio-plane policy: persist it, then apply it to every
16911
+ * live broker — including DERIVED ones, whose restreamer would otherwise
16912
+ * keep serving an `m=audio` grafted from its transcode egress.
16913
+ *
16914
+ * Persist BEFORE applying. If the process dies between the two, the next
16915
+ * boot re-applies from the store and the camera is silent as asked; the
16916
+ * other order loses the operator's decision and the camera is audible again
16917
+ * with the switch still reading off.
16918
+ */
16919
+ async setDeviceAudioMute(input) {
16920
+ const { deviceId, muted } = input;
16921
+ const prior = this.deviceOverrides.get(deviceId)?.audioMuted === true;
16922
+ const next = {
16923
+ ...this.deviceOverrides.get(deviceId),
16924
+ audioMuted: muted
16925
+ };
16926
+ this.deviceOverrides.set(deviceId, next);
16927
+ this.deviceOverridePersister?.(this.deviceOverrides);
16928
+ const brokers = this.brokersOfDevice(deviceId);
16929
+ for (const broker of brokers) broker.setAudioMuted(muted);
16930
+ if (prior !== muted) {
16931
+ this.logger.info("broker audio mute set", {
16932
+ tags: { deviceId },
16933
+ meta: {
16934
+ muted,
16935
+ brokers: brokers.length
16936
+ }
16937
+ });
16938
+ this.audioPlaneState.delete(deviceId);
16939
+ }
16940
+ return Promise.resolve({
16941
+ muted,
16942
+ appliedBrokers: brokers.filter((b) => b.isAudioMuted()).length
16943
+ });
16944
+ }
16458
16945
  async fanOutRegistration(_deviceId, brokerId, _cam, broker) {
16459
16946
  if (this.webrtcServer) this.webrtcServer.registerBroker(brokerId, broker);
16460
16947
  }
@@ -17936,6 +18423,17 @@ var AdaptiveSession = class AdaptiveSession {
17936
18423
  acquireTranscodeEgress;
17937
18424
  /** The acquired egress, while this session holds a reference to it. */
17938
18425
  transcodeEgressLeg = null;
18426
+ /**
18427
+ * An acquire started at the ANSWER and not yet consumed by the feed.
18428
+ *
18429
+ * Read-and-null-out is the whole ownership protocol: whichever of
18430
+ * `startTranscodeFeed` and `releaseTranscodeEgress` reads a non-null value
18431
+ * owns the release, and since both read it synchronously exactly one can.
18432
+ * Without that, a session closing while its prewarm is in flight either
18433
+ * double-releases (the manager logs it as a consumer bookkeeping bug) or
18434
+ * leaks a child the primitive has no TTL to reclaim.
18435
+ */
18436
+ transcodeEgressPending = null;
17939
18437
  /** Unsubscribe from the acquired egress's video plane. */
17940
18438
  detachTranscodeEgress = null;
17941
18439
  /** Codec actually negotiated with the browser after SDP answer. */
@@ -18575,6 +19073,7 @@ var AdaptiveSession = class AdaptiveSession {
18575
19073
  negotiated: this.negotiatedCodec,
18576
19074
  needsTranscode: this.needsTranscode
18577
19075
  } });
19076
+ this.prewarmTranscodeEgress();
18578
19077
  if (this.debug) this.logger.info("Codec negotiation detail", { meta: {
18579
19078
  phase: "session",
18580
19079
  sessionId: this.sessionId,
@@ -18773,6 +19272,7 @@ var AdaptiveSession = class AdaptiveSession {
18773
19272
  setRemoteMs,
18774
19273
  createAnswerMs
18775
19274
  } });
19275
+ this.prewarmTranscodeEgress();
18776
19276
  if (this.debug) this.logger.info("Codec negotiation detail (client-offer)", { meta: {
18777
19277
  phase: "session",
18778
19278
  sessionId: this.sessionId,
@@ -19493,6 +19993,51 @@ var AdaptiveSession = class AdaptiveSession {
19493
19993
  * unchanged — the request asks for `audio: 'passthrough'` precisely so the
19494
19994
  * child does not encode a plane this session never reads.
19495
19995
  */
19996
+ /**
19997
+ * Start the egress acquire NOW, in parallel with ICE and DTLS.
19998
+ *
19999
+ * `needsTranscode` is final the moment the answer's codec is known — the
20000
+ * "Codec negotiated" line already prints it — but the acquire used to happen
20001
+ * inside `startFeedingFrames`, which runs only after the DTLS wait. On camera
20002
+ * 615 that put the leg ~2 s after session creation and the first packet ~5 s
20003
+ * after that, against a browser watchdog that re-offers every ~5 s: the viewer
20004
+ * was always gone before the media existed, and the next session paid for a
20005
+ * cold child again. Three minutes of that on 2026-08-07 22:17–22:20.
20006
+ *
20007
+ * Only the ffmpeg moves. The ATTACH stays behind DTLS, because `sendRtp`
20008
+ * silently drops before the transport is connected.
20009
+ *
20010
+ * Idempotent and safe to call from any negotiation path; a session that needs
20011
+ * no transcode does nothing. Public for the same reason
20012
+ * {@link armSourceSilenceWatchdog} is — the acquire/release contract is
20013
+ * testable without standing up werift.
20014
+ */
20015
+ prewarmTranscodeEgress() {
20016
+ if (this.closed) return;
20017
+ if (!this.needsTranscode) return;
20018
+ if (this.transcodeEgressPending !== null || this.transcodeEgressLeg !== null) return;
20019
+ const acquire = this.acquireTranscodeEgress;
20020
+ if (acquire === null) return;
20021
+ this.logger.info("Transcode leg: prewarming the egress while ICE/DTLS negotiate", { meta: {
20022
+ phase: "session",
20023
+ sessionId: this.sessionId,
20024
+ deviceId: this.deviceId,
20025
+ sourceCodec: this.sourceCodec
20026
+ } });
20027
+ this.transcodeEgressPending = acquire({
20028
+ profile: this.transcodeProfile,
20029
+ sessionId: this.sessionId
20030
+ });
20031
+ }
20032
+ /**
20033
+ * Take ownership of the in-flight prewarm, if there is one. Nulls the field
20034
+ * synchronously so the release path can never also claim it — see the field.
20035
+ */
20036
+ claimPrewarmedEgress() {
20037
+ const pending = this.transcodeEgressPending;
20038
+ this.transcodeEgressPending = null;
20039
+ return pending;
20040
+ }
19496
20041
  startTranscodeFeed(signal) {
19497
20042
  const acquire = this.acquireTranscodeEgress;
19498
20043
  if (acquire === null) {
@@ -19504,20 +20049,32 @@ var AdaptiveSession = class AdaptiveSession {
19504
20049
  if (!this.closed) this.close();
19505
20050
  return;
19506
20051
  }
20052
+ const prewarmed = this.claimPrewarmedEgress();
19507
20053
  this.logger.info(`Starting ${this.sourceCodec}→H.264 Baseline transcode leg`, { meta: {
19508
20054
  phase: "session",
19509
20055
  sessionId: this.sessionId,
19510
20056
  deviceId: this.deviceId,
19511
20057
  sourceCodec: this.sourceCodec,
19512
- mode: "egress-primitive"
20058
+ mode: "egress-primitive",
20059
+ prewarmed: prewarmed !== null
19513
20060
  } });
20061
+ const acquireNow = () => acquire({
20062
+ profile: this.transcodeProfile,
20063
+ sessionId: this.sessionId
20064
+ });
19514
20065
  (async () => {
19515
20066
  let leg;
19516
20067
  try {
19517
- leg = await acquire({
19518
- profile: this.transcodeProfile,
19519
- sessionId: this.sessionId
19520
- });
20068
+ leg = await (prewarmed === null ? acquireNow() : prewarmed.catch((err) => {
20069
+ if (signal.aborted || this.closed) throw err;
20070
+ this.logger.warn("Transcode leg: the prewarmed acquire failed — retrying now that the feed has started", { meta: {
20071
+ phase: "session",
20072
+ sessionId: this.sessionId,
20073
+ deviceId: this.deviceId,
20074
+ error: require_dist.errMsg(err)
20075
+ } });
20076
+ return acquireNow();
20077
+ }));
19521
20078
  } catch (err) {
19522
20079
  this.logger.error("Transcode feed: acquiring the egress transcode FAILED", { meta: {
19523
20080
  phase: "session",
@@ -19585,6 +20142,14 @@ var AdaptiveSession = class AdaptiveSession {
19585
20142
  try {
19586
20143
  detach?.();
19587
20144
  } catch {}
20145
+ const orphan = this.claimPrewarmedEgress();
20146
+ if (orphan !== null) orphan.then((prewarmedLeg) => prewarmedLeg.release()).catch((err) => {
20147
+ this.logger.debug("Transcode leg: the unclaimed prewarm ended without a handle", { meta: {
20148
+ phase: "session",
20149
+ sessionId: this.sessionId,
20150
+ error: require_dist.errMsg(err)
20151
+ } });
20152
+ });
19588
20153
  const leg = this.transcodeEgressLeg;
19589
20154
  this.transcodeEgressLeg = null;
19590
20155
  if (!leg) return;