@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
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "../chunk-DnnnRqeS.mjs";
2
- import { B as invocationFromEncodeProfile, C as addonWidgetsSourceCapability, Ct as literal, D as cameraStreamsCapability, Dt as string, E as buildFfmpegArgs$1, Et as record, F as egressTranscodeSharingKey, I as egressTransportFromRequest, O as createHwAccelCache, Ot as union, Tt as object, U as maskUrlCredentials, V as isSoftwareDecode, Z as resolveEgressDecodeHwAccel, at as CAM_PROFILE_ORDER, bt as boolean, ct as createEvent, dt as makeProfileBrokerId, et as streamBrokerCapability, f as EncodeProfileSchema, ft as makeSourceBrokerId, ht as parseProfileBrokerId, i as AUDIO_PRESETS, it as BaseAddon, kt as EventCategory, nt as webrtcSessionCapability, ot as DeviceFeature, pt as nodePin, rt as errMsg, st as DeviceType, ut as isEvent, v as RATE_CONTROL_RELAXED, vt as _enum, wt as number, x as RingBuffer, xt as discriminatedUnion, y as RATE_CONTROL_TIGHT, yt as array } from "../dist-DOu93i_g.mjs";
2
+ import { $ as errMsg, At as EventCategory, Dt as record, E as createHwAccelCache, Et as object, N as egressTranscodeSharingKey, Ot as string, P as egressTransportFromRequest, Q as webrtcSessionCapability, S as addonWidgetsSourceCapability, St as discriminatedUnion, T as cameraStreamsCapability, Tt as number, X as streamBrokerCapability, _ as RATE_CONTROL_RELAXED, at as BaseAddon, b as RingBuffer, bt as array, ct as DeviceType, d as EncodeProfileSchema, dt as isEvent, et as AUDIO_PRESETS, ft as makeProfileBrokerId, gt as parseProfileBrokerId, it as isSoftwareDecode, kt as union, lt as createEvent, mt as nodePin, nt as buildFfmpegArgs$1, ot as CAM_PROFILE_ORDER, pt as makeSourceBrokerId, q as resolveEgressDecodeHwAccel, rt as invocationFromEncodeProfile, st as DeviceFeature, tt as Fmp4BoxSplitter, v as RATE_CONTROL_TIGHT, wt as literal, xt as boolean, yt as _enum, z as maskUrlCredentials } from "../dist-CUj9mdhh.mjs";
3
3
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
4
4
  import { n as createFileDataPlaneHandler } from "../model-download-service-D8B-4ktF-BB7oZL3y.mjs";
5
5
  import { createRequire } from "node:module";
@@ -10,6 +10,7 @@ import { pathToFileURL } from "node:url";
10
10
  import * as os from "node:os";
11
11
  import { networkInterfaces, tmpdir } from "node:os";
12
12
  import * as fs from "node:fs";
13
+ import "node:fs";
13
14
  import * as path$1 from "node:path";
14
15
  import path, { join } from "node:path";
15
16
  import * as net$1 from "node:net";
@@ -1569,6 +1570,21 @@ var RtspRestreamer = class {
1569
1570
  * trackID=1. Muted sessions still get the video-only SDP.
1570
1571
  */
1571
1572
  egressAudioBlock = null;
1573
+ /**
1574
+ * BROKER-SIDE AUDIO MUTE (D83) — device policy, set by `StreamBroker`.
1575
+ *
1576
+ * Distinct from a session's own `isMuted()`, which is a PULLER's choice
1577
+ * (the `/muted` URL a local decoder asks for). This one overrides every
1578
+ * puller: while true, `getSdp()` serves the video-only SDP to everyone, so
1579
+ * a client that connects during the mute never declares an audio stream it
1580
+ * will not receive.
1581
+ *
1582
+ * NOT cleared by {@link resetStreamState}. That method resets what belongs
1583
+ * to one CONNECTION (keyframe caches, codec params, SDP); an operator's
1584
+ * mute belongs to the device and must survive a suspend/resume — otherwise
1585
+ * a broker going idle overnight would wake up distributing audio again.
1586
+ */
1587
+ deviceAudioMuted = false;
1572
1588
  /** Cached last keyframe NALs (SPS/PPS + IDR) for late-joining clients. */
1573
1589
  lastKeyframeNals = [];
1574
1590
  lastKeyframeNalBytes = 0;
@@ -1731,11 +1747,40 @@ var RtspRestreamer = class {
1731
1747
  return this.codecParams;
1732
1748
  }
1733
1749
  getSdp() {
1750
+ if (this.deviceAudioMuted) return this.getMutedSdp();
1734
1751
  return this.sdp;
1735
1752
  }
1736
1753
  getMutedSdp() {
1737
1754
  return this.mutedSdp ?? this.sdp;
1738
1755
  }
1756
+ /**
1757
+ * Apply the broker-side audio mute. Returns how many audio-bearing pull
1758
+ * sessions were closed so they re-`DESCRIBE` against the new SDP.
1759
+ *
1760
+ * Idempotent — the same value twice closes nothing. Sessions that pulled the
1761
+ * `/muted` URL are left alone: they never had an audio track to gain or
1762
+ * lose, and killing a local decoder's feed to change nothing would be a
1763
+ * decode gap for free.
1764
+ */
1765
+ setAudioMuted(muted) {
1766
+ if (this.deviceAudioMuted === muted) return 0;
1767
+ this.deviceAudioMuted = muted;
1768
+ const audioBearing = [...this.sessions.values()].filter((s) => !s.isMuted());
1769
+ for (const session of audioBearing) this.killSession(session.getSessionId());
1770
+ if (audioBearing.length > 0) this.logger?.info("audio mute changed — closing audio-bearing RTSP pulls so they re-DESCRIBE", {
1771
+ tags: { deviceId: this.deviceId },
1772
+ meta: {
1773
+ streamName: this.streamName,
1774
+ muted,
1775
+ closed: audioBearing.length
1776
+ }
1777
+ });
1778
+ return audioBearing.length;
1779
+ }
1780
+ /** The broker-side audio mute currently in force for this restreamer. */
1781
+ isDeviceAudioMuted() {
1782
+ return this.deviceAudioMuted;
1783
+ }
1739
1784
  getSessionCount() {
1740
1785
  return this.sessions.size;
1741
1786
  }
@@ -2004,6 +2049,7 @@ var RtspRestreamer = class {
2004
2049
  * would mean dropping ~300 ms of audio at every join.
2005
2050
  */
2006
2051
  pushAudioRtpPassthrough(rtpData) {
2052
+ if (this.deviceAudioMuted) return;
2007
2053
  if (this.sessions.size === 0) return;
2008
2054
  const rtpPacket = {
2009
2055
  data: rtpData,
@@ -2960,6 +3006,147 @@ function buildAudioCodecCapApi(api) {
2960
3006
  };
2961
3007
  }
2962
3008
  //#endregion
3009
+ //#region src/stream-broker/stream-broker/dead-decode-backends.ts
3010
+ /**
3011
+ * "This decode backend cannot create a device in THIS process" — remembered
3012
+ * across children, not per child.
3013
+ *
3014
+ * ## The thing this stops
3015
+ *
3016
+ * `-hwaccel vaapi` works for the decoder addon on this hub (ten concurrent 4K
3017
+ * decodes) and returns ENOMEM inside the stream-broker runner: `code=244`,
3018
+ * "Device creation failed: -12 … Cannot allocate memory". Every egress child
3019
+ * therefore spends ~3 s dying before its own software retry rescues it.
3020
+ *
3021
+ * That was already reported — `EgressTranscodeManager.onDecodeHwAccelFailed`
3022
+ * poisoned the broker's 60 s `egressHwAccelCache`. The memo is the wrong shape
3023
+ * for the verdict: it exists to avoid a per-session cross-process
3024
+ * `decoder.getInfo`, so it expires on a **read** cadence, and one minute later
3025
+ * the decoder answers `vaapi` again and the next cold child re-pays the death.
3026
+ * Measured on camera 615 on 2026-08-07: the hardware failure recurs at 22:17:07,
3027
+ * 22:18:42, 22:28:48, 22:30:52, 22:32:39, 22:34:07, 22:52:07 — once per memo
3028
+ * window, forever, on a hub where it has never once succeeded.
3029
+ *
3030
+ * ## Why this is a quarantine and not a tombstone
3031
+ *
3032
+ * A permanent verdict is wrong for the failure that is actually observed:
3033
+ * ENOMEM on device creation is a RESOURCE condition, and a resource condition
3034
+ * can clear (the operator gives the container more of the device, another
3035
+ * process releases it). A process that decided "never again" at 03:00 would
3036
+ * still be decoding in software at noon with a working accelerator, and nothing
3037
+ * would ever say so.
3038
+ *
3039
+ * So the verdict LASTS and escalates: each consecutive failure of the same
3040
+ * backend widens its quarantine — 30 min, 2 h, then 6 h — so a hub where the
3041
+ * backend is genuinely dead pays one probe per six hours instead of one per
3042
+ * minute, while a hub where it recovers still finds out.
3043
+ *
3044
+ * The FIRST rung is deliberately not a minute. One report is already a strong
3045
+ * signal: the manager only reports a backend after its child produced ZERO
3046
+ * packets *and* the software retry proved the same argv otherwise works, which
3047
+ * is a statement about the backend and not about the source. And the cost of
3048
+ * being wrong is bounded and already being paid — the egress encodes with
3049
+ * `libx264` either way, so an over-long quarantine costs a software DECODE on a
3050
+ * path that is decoding in software today, 100 % of the time.
3051
+ *
3052
+ * The invalidations are motivated, and there are exactly three:
3053
+ *
3054
+ * 1. **The window elapses.** The next acquire probes hardware once. If it
3055
+ * works, {@link DeadDecodeBackends.markAlive} clears the record and the
3056
+ * escalation starts from zero again.
3057
+ * 2. **The backend changes.** The record is keyed by backend name, so an
3058
+ * operator switching the decoder from vaapi to qsv is obeyed immediately —
3059
+ * qsv has no record.
3060
+ * 3. **The runner respawns.** This is instance state on the broker manager,
3061
+ * never a module global, for the same reason `HwAccelCache` is: a module
3062
+ * global outlives an addon respawn, and an addon respawn is exactly the
3063
+ * event after which a stale verdict must not survive.
3064
+ *
3065
+ * ## Why it lives here and not in `@camstack/types`
3066
+ *
3067
+ * `addon-export-hap` has the identical gap (its `failedBackend` memo forgets on
3068
+ * the same 60 s cadence) and this is deliberately NOT shared with it yet: a
3069
+ * framework package is only live once a `@camstack/server` containing it is
3070
+ * installed, whereas this ships with `camstack deploy packages/addon-pipeline`.
3071
+ * The cross-PROCESS gap D67 names — addons may not import each other and no cap
3072
+ * carries "this backend is dead on this node right now" — is unchanged either
3073
+ * way; what is closed here is the broker's own repetition of the cost.
3074
+ */
3075
+ /**
3076
+ * How long a backend stays quarantined after its 1st, 2nd, 3rd+ consecutive
3077
+ * failure. Escalating rather than fixed, and it starts at HALF AN HOUR: a
3078
+ * client watchdog cycles in seconds, so any first rung measured in minutes is
3079
+ * re-paid by the very churn this exists to stop. The last rung makes a
3080
+ * permanently-broken backend cost ~nothing while still being re-probed.
3081
+ */
3082
+ var DEAD_BACKEND_QUARANTINE_MS = [
3083
+ 18e5,
3084
+ 72e5,
3085
+ 216e5
3086
+ ];
3087
+ /**
3088
+ * The decoder's answer, overruled while the backend it names is quarantined.
3089
+ *
3090
+ * Composed rather than folded into `resolveEgressDecodeHwAccel`: that function
3091
+ * answers "what SHOULD decode this", which is a per-node property of the
3092
+ * hardware; this answers "what has been observed to work in THIS process",
3093
+ * which is a property of the runner. Keeping them apart is what stops a
3094
+ * runner-local ENOMEM from being mistaken for a node that has no accelerator —
3095
+ * the first is a flow bug to fix, the second a capability limit to accept.
3096
+ */
3097
+ async function resolveWithQuarantine(input) {
3098
+ const backend = await input.resolve();
3099
+ if (backend === null || !input.dead.isQuarantined(backend)) return {
3100
+ backend,
3101
+ veto: null
3102
+ };
3103
+ return {
3104
+ backend: null,
3105
+ veto: {
3106
+ backend,
3107
+ failures: input.dead.failuresOf(backend),
3108
+ remainingMs: input.dead.remainingMs(backend)
3109
+ }
3110
+ };
3111
+ }
3112
+ function createDeadDecodeBackends(options = {}) {
3113
+ const now = options.now ?? (() => Date.now());
3114
+ const records = /* @__PURE__ */ new Map();
3115
+ const quarantineFor = (failures) => {
3116
+ return DEAD_BACKEND_QUARANTINE_MS[Math.min(failures, DEAD_BACKEND_QUARANTINE_MS.length) - 1] ?? DEAD_BACKEND_QUARANTINE_MS[0] ?? 0;
3117
+ };
3118
+ return {
3119
+ markDead(backend) {
3120
+ const failures = (records.get(backend)?.failures ?? 0) + 1;
3121
+ const quarantineMs = quarantineFor(failures);
3122
+ records.set(backend, {
3123
+ failures,
3124
+ until: now() + quarantineMs
3125
+ });
3126
+ return {
3127
+ backend,
3128
+ failures,
3129
+ quarantineMs
3130
+ };
3131
+ },
3132
+ markAlive(backend) {
3133
+ records.delete(backend);
3134
+ },
3135
+ isQuarantined(backend) {
3136
+ const record = records.get(backend);
3137
+ return record !== void 0 && now() < record.until;
3138
+ },
3139
+ remainingMs(backend) {
3140
+ const record = records.get(backend);
3141
+ if (record === void 0) return 0;
3142
+ return Math.max(0, record.until - now());
3143
+ },
3144
+ failuresOf(backend) {
3145
+ return records.get(backend)?.failures ?? 0;
3146
+ }
3147
+ };
3148
+ }
3149
+ //#endregion
2963
3150
  //#region src/stream-broker/stream-broker/ffmpeg-invocation.ts
2964
3151
  /** The full set of hwaccel settings (for validation + option surfaces). */
2965
3152
  var FFMPEG_HW_ACCEL_SETTINGS = [
@@ -3184,182 +3371,291 @@ function pushDimension(into, constraint, requested, known) {
3184
3371
  reason: `source ${known} > requested ${requested}`
3185
3372
  });
3186
3373
  }
3187
- //#endregion
3188
- //#region src/stream-broker/stream-broker/fmp4-box-splitter.ts
3189
- var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
3190
- /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
3191
- var BOX_HEADER_BYTES = 8;
3192
- var LARGE_BOX_HEADER_BYTES = 16;
3193
- var Fmp4BoxSplitter = class {
3194
- maxUnitBytes;
3195
- /** Bytes of the CURRENT unit plus any partial box after it. */
3196
- buffer = new Uint8Array(0);
3197
- /** Where the current unit starts inside {@link buffer}. */
3198
- unitStart = 0;
3199
- /** Where the box scanner has reached inside {@link buffer}. */
3200
- cursor = 0;
3201
- state = "init";
3202
- nextSequence = 0;
3203
- faultReason = null;
3204
- interstitial = /* @__PURE__ */ new Set();
3205
- constructor(options = {}) {
3206
- this.maxUnitBytes = options.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES;
3207
- }
3208
- /**
3209
- * Non-null once the stream cannot be split. The splitter emits nothing
3210
- * further, so a caller polls this to kill the child rather than watching a
3211
- * silent stall a fragmenter that quietly stops producing looks exactly like
3212
- * a camera with no motion.
3213
- */
3214
- get fault() {
3215
- return this.faultReason;
3216
- }
3217
- /** Bytes currently held. The memory bound, observable rather than asserted. */
3218
- get pendingBytes() {
3219
- return this.buffer.length - this.unitStart;
3220
- }
3221
- /**
3222
- * Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
3223
- * a stray `sidx`. Reported rather than dropped in silence: they are legal and
3224
- * useless to a fragment consumer, but a type nobody expected showing up here
3225
- * is the first symptom of a muxer that is not writing what we think it is.
3226
- */
3227
- get discardedInterstitialTypes() {
3228
- return [...this.interstitial];
3229
- }
3230
- /**
3231
- * Feed bytes; get back whatever units completed. Returns `[]` once faulted.
3374
+ /**
3375
+ * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
3376
+ * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
3377
+ *
3378
+ * ## Why a plane and not a callback
3379
+ *
3380
+ * The operator's requirement for HKSV was explicit: the live fMP4 source built
3381
+ * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
3382
+ * CamStack as an additional videoclip source alongside the recorder and the NC
3383
+ * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
3384
+ * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
3385
+ * a second ffmpeg child of the same camera. So this is the same shape the
3386
+ * broker's other multi-consumer surfaces already have
3387
+ * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
3388
+ * one producer.
3389
+ *
3390
+ * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
3391
+ * clip source; both are named here so the seam is not re-invented, and neither
3392
+ * is built.
3393
+ *
3394
+ * ## The init segment is RETAINED
3395
+ *
3396
+ * A subscriber that attaches mid-stream the clip consumer joining an already
3397
+ * running HKSV session, which is the whole dual-use case receives the
3398
+ * retained `ftyp`+`moov` as its first packet and then live fragments. Without
3399
+ * retention its fragments are undecodable and the failure looks like a codec
3400
+ * problem.
3401
+ *
3402
+ * ## A slow subscriber is CLOSED, never silently gapped
3403
+ *
3404
+ * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
3405
+ * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
3406
+ * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
3407
+ * shows a clip that fails to play with nothing anywhere saying why. So a
3408
+ * subscription whose queue overflows is ENDED with a reason, loudly, and the
3409
+ * other subscriptions are untouched.
3410
+ *
3411
+ * ## The PREBUFFER (phase 3)
3412
+ *
3413
+ * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
3414
+ * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
3415
+ * motion edge has none. So the plane optionally retains the last few fragments
3416
+ * and replays them to a subscriber that asks for them.
3417
+ *
3418
+ * Three things this ring gets right, each of which is a measured fact rather
3419
+ * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
3420
+ *
3421
+ * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
3422
+ * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
3423
+ * window. A time-only bound is a per-camera RAM figure nobody can predict.
3424
+ * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
3425
+ * splitter deliberately never computes a fragment's duration (a second
3426
+ * opinion about a fact the muxer owns), and a prebuffer cares about how long
3427
+ * ago the bytes turned up, which is exactly what arrival time answers.
3428
+ * - **A replay is not backlog.** A subscriber taking N retained fragments gets
3429
+ * its queue capacity raised by N for them, because closing a subscriber as a
3430
+ * slow consumer for the prebuffer it explicitly asked for would be the
3431
+ * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
3432
+ * ring of 4, the guaranteed one.
3433
+ *
3434
+ * ## `isLast`
3435
+ *
3436
+ * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
3437
+ * `isLast` — a generator that finishes without it produces the twelve-second
3438
+ * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
3439
+ * plane therefore computes it at DELIVERY time: a packet is last when the plane
3440
+ * has ended and nothing remains queued behind it. A subscription that ends
3441
+ * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
3442
+ * the future delegate must not open an HDS stream it cannot feed.
3443
+ */
3444
+ var DEFAULT_QUEUE_CAPACITY = 4;
3445
+ var Fmp4FragmentPlane = class {
3446
+ logger;
3447
+ prebuffer;
3448
+ now;
3449
+ subscriptions = /* @__PURE__ */ new Map();
3450
+ /** The last init unit seen, handed to every later subscriber. */
3451
+ retainedInit = null;
3452
+ ended = false;
3453
+ /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
3454
+ ring = [];
3455
+ ringBytes = 0;
3456
+ constructor(logger, prebuffer, now = Date.now) {
3457
+ this.logger = logger;
3458
+ this.prebuffer = prebuffer;
3459
+ this.now = now;
3460
+ }
3461
+ get subscriberCount() {
3462
+ return this.subscriptions.size;
3463
+ }
3464
+ /** True once {@link end} has been called — no further units are accepted. */
3465
+ get isEnded() {
3466
+ return this.ended;
3467
+ }
3468
+ /** What the prebuffer ring holds right now. All zeroes when disabled. */
3469
+ prebufferStats() {
3470
+ const oldest = this.ring[0];
3471
+ return {
3472
+ fragments: this.ring.length,
3473
+ bytes: this.ringBytes,
3474
+ spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
3475
+ };
3476
+ }
3477
+ subscribe(input) {
3478
+ const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
3479
+ const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
3480
+ const sub = {
3481
+ id: `fmp4-${randomUUID()}`,
3482
+ tag: input.tag,
3483
+ subscribedAt: this.now(),
3484
+ capacity: requested + replay.length,
3485
+ queue: [],
3486
+ delivered: 0,
3487
+ closedReason: null,
3488
+ wake: null,
3489
+ iterating: false
3490
+ };
3491
+ this.subscriptions.set(sub.id, sub);
3492
+ if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
3493
+ for (const retained of replay) this.enqueue(sub, retained.unit);
3494
+ if (this.ended) this.closeSubscription(sub, "ended");
3495
+ this.logger?.info("fmp4 plane: subscribed", { meta: {
3496
+ subscriptionId: sub.id,
3497
+ tag: sub.tag,
3498
+ hasRetainedInit: this.retainedInit !== null,
3499
+ prebufferFragments: replay.length,
3500
+ prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
3501
+ } });
3502
+ return this.facade(sub);
3503
+ }
3504
+ /**
3505
+ * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
3506
+ * emits exactly one per child, and a second means the child was respawned, in
3507
+ * which case the old one describes a stream that no longer exists.
3232
3508
  */
3233
- push(chunk) {
3234
- if (this.faultReason !== null || chunk.length === 0) return [];
3235
- this.append(chunk);
3236
- if (this.pendingBytes > this.maxUnitBytes) return this.fail(`a single fMP4 unit exceeded ${this.maxUnitBytes} bytes — this stream is not fragmented`);
3237
- return this.drainBoxes();
3238
- }
3239
- append(chunk) {
3240
- if (this.buffer.length === 0) {
3241
- this.buffer = chunk.slice();
3242
- return;
3509
+ publish(unit) {
3510
+ if (this.ended) return;
3511
+ if (unit.kind === "init") {
3512
+ this.retainedInit = unit;
3513
+ this.ring.length = 0;
3514
+ this.ringBytes = 0;
3515
+ } else this.retain(unit);
3516
+ for (const sub of this.subscriptions.values()) {
3517
+ if (sub.closedReason !== null) continue;
3518
+ this.enqueue(sub, unit);
3243
3519
  }
3244
- const next = new Uint8Array(this.buffer.length + chunk.length);
3245
- next.set(this.buffer, 0);
3246
- next.set(chunk, this.buffer.length);
3247
- this.buffer = next;
3248
3520
  }
3249
- /** Consume every COMPLETE top-level box now in the buffer. */
3250
- drainBoxes() {
3251
- const units = [];
3252
- for (;;) {
3253
- const header = this.readHeader();
3254
- if (this.faultReason !== null) return units;
3255
- if (header === null) break;
3256
- if (this.cursor + header.totalBytes > this.buffer.length) break;
3257
- const boxStart = this.cursor;
3258
- const boxEnd = boxStart + header.totalBytes;
3259
- this.cursor = boxEnd;
3260
- const unit = this.consumeBox(header.type, boxStart, boxEnd);
3261
- if (this.faultReason !== null) return units;
3262
- if (unit !== null) units.push(unit);
3263
- }
3264
- this.compact();
3265
- return units;
3266
- }
3267
- /**
3268
- * Apply one box to the state machine. Returns a unit when this box CLOSED
3269
- * one, `null` otherwise.
3270
- */
3271
- consumeBox(type, boxStart, boxEnd) {
3272
- if (this.state === "init") {
3273
- if (type !== "moof") return null;
3274
- if (boxStart === this.unitStart) {
3275
- this.fail("a moof arrived before any initialisation box — there is no ftyp/moov to send");
3276
- return null;
3277
- }
3278
- const init = this.emit("init", this.unitStart, boxStart);
3279
- this.unitStart = boxStart;
3280
- this.state = "fragment";
3281
- return init;
3282
- }
3283
- if (this.state === "idle") {
3284
- if (type !== "moof") {
3285
- this.interstitial.add(type);
3286
- this.unitStart = boxEnd;
3287
- return null;
3288
- }
3289
- this.unitStart = boxStart;
3290
- this.state = "fragment";
3291
- return null;
3521
+ /**
3522
+ * The producer stopped. Every subscriber drains what it holds; its final
3523
+ * packet carries `isLast`, and its generator then completes.
3524
+ */
3525
+ end(reason = "producer ended") {
3526
+ if (this.ended) return;
3527
+ this.ended = true;
3528
+ this.logger?.info("fmp4 plane: ended", { meta: {
3529
+ reason,
3530
+ subscribers: this.subscriptions.size
3531
+ } });
3532
+ for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
3533
+ }
3534
+ listSubscribers() {
3535
+ return [...this.subscriptions.values()].map((s) => ({
3536
+ tag: s.tag,
3537
+ subscribedAt: s.subscribedAt,
3538
+ delivered: s.delivered,
3539
+ closedReason: s.closedReason
3540
+ }));
3541
+ }
3542
+ /** End and forget everything. Idempotent. */
3543
+ dispose() {
3544
+ this.end("disposed");
3545
+ this.subscriptions.clear();
3546
+ this.retainedInit = null;
3547
+ this.ring.length = 0;
3548
+ this.ringBytes = 0;
3549
+ }
3550
+ /**
3551
+ * Add one fragment to the ring and evict from the front until BOTH bounds
3552
+ * hold. Eviction is oldest-first, which is the one place in this file where
3553
+ * dropping is correct: the ring is context, not stream — nobody is mid-decode
3554
+ * on it, and a subscriber only ever receives a contiguous tail of it.
3555
+ */
3556
+ retain(unit) {
3557
+ const prebuffer = this.prebuffer;
3558
+ if (prebuffer === void 0) return;
3559
+ const arrivedAt = this.now();
3560
+ this.ring.push({
3561
+ unit,
3562
+ arrivedAt
3563
+ });
3564
+ this.ringBytes += unit.data.length;
3565
+ const cutoff = arrivedAt - prebuffer.windowMs;
3566
+ while (this.ring.length > 0) {
3567
+ const oldest = this.ring[0];
3568
+ if (oldest === void 0) break;
3569
+ const tooOld = oldest.arrivedAt < cutoff;
3570
+ const tooBig = this.ringBytes > prebuffer.maxBytes;
3571
+ if (!tooOld && !tooBig || this.ring.length === 1) break;
3572
+ this.ring.shift();
3573
+ this.ringBytes -= oldest.unit.data.length;
3292
3574
  }
3293
- if (type !== "mdat") return null;
3294
- const fragment = this.emit("fragment", this.unitStart, boxEnd);
3295
- this.unitStart = boxEnd;
3296
- this.state = "idle";
3297
- return fragment;
3298
3575
  }
3299
3576
  /**
3300
- * Parse the header at {@link cursor}, or `null` when too few bytes have
3301
- * arrived to know. Faults on a size the splitter cannot honour.
3577
+ * The ring as a subscriber should receive it window applied AT SUBSCRIBE
3578
+ * time, not only at publish time. A camera that went quiet keeps its last
3579
+ * fragment in the ring indefinitely (see the never-evict-the-newest rule),
3580
+ * and replaying a 40-second-old fragment as "prebuffer" would put stale video
3581
+ * at the head of a clip iOS presents as the moment of the event.
3302
3582
  */
3303
- readHeader() {
3304
- const available = this.buffer.length - this.cursor;
3305
- if (available < BOX_HEADER_BYTES) return null;
3306
- const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
3307
- const size = view.getUint32(this.cursor);
3308
- 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);
3309
- if (size === 0) {
3310
- this.fail(`box "${type}" declares size 0 (to EOF) — an unbounded box cannot be fragmented`);
3311
- return null;
3312
- }
3313
- if (size === 1) {
3314
- if (available < LARGE_BOX_HEADER_BYTES) return null;
3315
- const large = view.getBigUint64(this.cursor + BOX_HEADER_BYTES);
3316
- if (large > BigInt(this.maxUnitBytes)) {
3317
- this.fail(`box "${type}" declares ${large} bytes, over the ${this.maxUnitBytes} byte bound`);
3318
- return null;
3319
- }
3320
- return {
3321
- type,
3322
- totalBytes: Number(large)
3323
- };
3324
- }
3325
- if (size < BOX_HEADER_BYTES) {
3326
- this.fail(`box "${type}" declares an impossible size of ${size} bytes`);
3327
- return null;
3583
+ trimmedRing() {
3584
+ const prebuffer = this.prebuffer;
3585
+ if (prebuffer === void 0) return [];
3586
+ const cutoff = this.now() - prebuffer.windowMs;
3587
+ return this.ring.filter((r) => r.arrivedAt >= cutoff);
3588
+ }
3589
+ enqueue(sub, unit) {
3590
+ if (sub.queue.length >= sub.capacity) {
3591
+ this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
3592
+ subscriptionId: sub.id,
3593
+ tag: sub.tag,
3594
+ capacity: sub.capacity,
3595
+ delivered: sub.delivered
3596
+ } });
3597
+ this.closeSubscription(sub, "slow-consumer");
3598
+ return;
3328
3599
  }
3329
- return {
3330
- type,
3331
- totalBytes: size
3332
- };
3600
+ sub.queue.push({
3601
+ kind: unit.kind,
3602
+ data: unit.data,
3603
+ sequence: unit.sequence,
3604
+ isLast: false
3605
+ });
3606
+ this.wake(sub);
3333
3607
  }
3334
- emit(kind, start, end) {
3335
- const sequence = this.nextSequence;
3336
- this.nextSequence += 1;
3608
+ closeSubscription(sub, reason) {
3609
+ if (sub.closedReason !== null) return;
3610
+ sub.closedReason = reason;
3611
+ if (reason === "slow-consumer") sub.queue.length = 0;
3612
+ this.wake(sub);
3613
+ }
3614
+ wake(sub) {
3615
+ const resume = sub.wake;
3616
+ sub.wake = null;
3617
+ resume?.();
3618
+ }
3619
+ facade(sub) {
3620
+ const plane = this;
3337
3621
  return {
3338
- kind,
3339
- data: this.buffer.slice(start, end),
3340
- sequence
3622
+ id: sub.id,
3623
+ tag: sub.tag,
3624
+ get delivered() {
3625
+ return sub.delivered;
3626
+ },
3627
+ get closedReason() {
3628
+ return sub.closedReason;
3629
+ },
3630
+ packets: () => plane.iterate(sub),
3631
+ release: () => {
3632
+ plane.closeSubscription(sub, "released");
3633
+ plane.subscriptions.delete(sub.id);
3634
+ }
3341
3635
  };
3342
3636
  }
3343
- /**
3344
- * Drop everything already emitted or discarded. Without this the buffer is
3345
- * the whole stream and the process dies in hours, not minutes.
3346
- */
3347
- compact() {
3348
- if (this.unitStart === 0) return;
3349
- this.buffer = this.buffer.slice(this.unitStart);
3350
- this.cursor -= this.unitStart;
3351
- this.unitStart = 0;
3352
- }
3353
- fail(reason) {
3354
- this.faultReason = reason;
3355
- this.buffer = new Uint8Array(0);
3356
- this.unitStart = 0;
3357
- this.cursor = 0;
3358
- return [];
3637
+ async *iterate(sub) {
3638
+ if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
3639
+ sub.iterating = true;
3640
+ for (;;) {
3641
+ const next = sub.queue.shift();
3642
+ if (next === void 0) {
3643
+ if (sub.closedReason !== null) return;
3644
+ await new Promise((resolve) => {
3645
+ sub.wake = resolve;
3646
+ });
3647
+ continue;
3648
+ }
3649
+ const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
3650
+ sub.delivered += 1;
3651
+ yield {
3652
+ ...next,
3653
+ isLast
3654
+ };
3655
+ if (isLast) return;
3656
+ }
3359
3657
  }
3360
3658
  };
3361
- //#endregion
3362
- //#region src/stream-broker/stream-broker/egress-fragment-child.ts
3363
3659
  var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
3364
3660
  /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
3365
3661
  var FRAGMENT_LOG_EVERY = 30;
@@ -3388,7 +3684,7 @@ var Fmp4FragmentChild = class {
3388
3684
  this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
3389
3685
  tags: { deviceId: this.args.deviceId },
3390
3686
  meta: {
3391
- brokerId: this.args.brokerId,
3687
+ sourceId: this.args.sourceId,
3392
3688
  decodeHwAccel: requested,
3393
3689
  error: errMsg(err)
3394
3690
  }
@@ -3424,7 +3720,7 @@ var Fmp4FragmentChild = class {
3424
3720
  this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
3425
3721
  tags: { deviceId: this.args.deviceId },
3426
3722
  meta: {
3427
- brokerId: this.args.brokerId,
3723
+ sourceId: this.args.sourceId,
3428
3724
  fragmentMs: this.args.fragmentMs,
3429
3725
  decodeHwAccel: decodeHwAccel ?? "software",
3430
3726
  argv: args.join(" ")
@@ -3478,7 +3774,7 @@ var Fmp4FragmentChild = class {
3478
3774
  this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
3479
3775
  tags: { deviceId: this.args.deviceId },
3480
3776
  meta: {
3481
- brokerId: this.args.brokerId,
3777
+ sourceId: this.args.sourceId,
3482
3778
  bytes: unit.data.length
3483
3779
  }
3484
3780
  });
@@ -3486,7 +3782,7 @@ var Fmp4FragmentChild = class {
3486
3782
  } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
3487
3783
  tags: { deviceId: this.args.deviceId },
3488
3784
  meta: {
3489
- brokerId: this.args.brokerId,
3785
+ sourceId: this.args.sourceId,
3490
3786
  unitsOut: this.unitsOut,
3491
3787
  bytes: unit.data.length,
3492
3788
  subscribers: this.args.plane.subscriberCount
@@ -3501,7 +3797,7 @@ var Fmp4FragmentChild = class {
3501
3797
  this.deps.logger.debug("fmp4 fragment child ffmpeg", {
3502
3798
  tags: { deviceId: this.args.deviceId },
3503
3799
  meta: {
3504
- brokerId: this.args.brokerId,
3800
+ sourceId: this.args.sourceId,
3505
3801
  line: line.trim()
3506
3802
  }
3507
3803
  });
@@ -3517,259 +3813,61 @@ var Fmp4FragmentChild = class {
3517
3813
  });
3518
3814
  child.once("exit", (code, signal) => {
3519
3815
  if (!settled) {
3520
- settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
3521
- return;
3522
- }
3523
- if (!isCurrent()) return;
3524
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
3525
- this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
3526
- tags: { deviceId: this.args.deviceId },
3527
- meta: {
3528
- brokerId: this.args.brokerId,
3529
- code,
3530
- signal,
3531
- unitsOut: this.unitsOut
3532
- }
3533
- });
3534
- this.args.plane.end("the fragment child exited");
3535
- this.deps.onChildExit?.(error);
3536
- });
3537
- });
3538
- }
3539
- /**
3540
- * The byte stream stopped being splittable. Not recoverable — the splitter
3541
- * cannot resynchronise mid-box — so the child is a corpse and every consumer
3542
- * has to be told, loudly, with the reason.
3543
- */
3544
- onFault(reason, wasLive, current, settle) {
3545
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
3546
- this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
3547
- tags: { deviceId: this.args.deviceId },
3548
- meta: {
3549
- brokerId: this.args.brokerId,
3550
- unitsOut: this.unitsOut,
3551
- interstitial: this.splitter.discardedInterstitialTypes,
3552
- reason
3553
- }
3554
- });
3555
- this.killChild();
3556
- settle(error);
3557
- if (wasLive && current) {
3558
- this.args.plane.end("the fragment child produced unsplittable output");
3559
- this.deps.onChildExit?.(error);
3560
- }
3561
- }
3562
- killChild() {
3563
- const child = this.child;
3564
- this.child = null;
3565
- if (child && !child.killed) try {
3566
- child.kill("SIGTERM");
3567
- } catch (err) {
3568
- this.deps.logger.warn("fmp4 fragment child: kill error", {
3569
- tags: { deviceId: this.args.deviceId },
3570
- meta: {
3571
- brokerId: this.args.brokerId,
3572
- error: errMsg(err)
3573
- }
3574
- });
3575
- }
3576
- }
3577
- };
3578
- //#endregion
3579
- //#region src/stream-broker/stream-broker/fmp4-fragment-plane.ts
3580
- /**
3581
- * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
3582
- * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
3583
- *
3584
- * ## Why a plane and not a callback
3585
- *
3586
- * The operator's requirement for HKSV was explicit: the live fMP4 source built
3587
- * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
3588
- * CamStack as an additional videoclip source alongside the recorder and the NC
3589
- * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
3590
- * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
3591
- * a second ffmpeg child of the same camera. So this is the same shape the
3592
- * broker's other multi-consumer surfaces already have
3593
- * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
3594
- * one producer.
3595
- *
3596
- * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
3597
- * clip source; both are named here so the seam is not re-invented, and neither
3598
- * is built.
3599
- *
3600
- * ## The init segment is RETAINED
3601
- *
3602
- * A subscriber that attaches mid-stream — the clip consumer joining an already
3603
- * running HKSV session, which is the whole dual-use case — receives the
3604
- * retained `ftyp`+`moov` as its first packet and then live fragments. Without
3605
- * retention its fragments are undecodable and the failure looks like a codec
3606
- * problem.
3607
- *
3608
- * ## A slow subscriber is CLOSED, never silently gapped
3609
- *
3610
- * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
3611
- * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
3612
- * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
3613
- * shows a clip that fails to play with nothing anywhere saying why. So a
3614
- * subscription whose queue overflows is ENDED with a reason, loudly, and the
3615
- * other subscriptions are untouched.
3616
- *
3617
- * ## `isLast`
3618
- *
3619
- * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
3620
- * `isLast` — a generator that finishes without it produces the twelve-second
3621
- * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
3622
- * plane therefore computes it at DELIVERY time: a packet is last when the plane
3623
- * has ended and nothing remains queued behind it. A subscription that ends
3624
- * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
3625
- * the future delegate must not open an HDS stream it cannot feed.
3626
- */
3627
- var DEFAULT_QUEUE_CAPACITY = 4;
3628
- var Fmp4FragmentPlane = class {
3629
- logger;
3630
- subscriptions = /* @__PURE__ */ new Map();
3631
- /** The last init unit seen, handed to every later subscriber. */
3632
- retainedInit = null;
3633
- ended = false;
3634
- constructor(logger) {
3635
- this.logger = logger;
3636
- }
3637
- get subscriberCount() {
3638
- return this.subscriptions.size;
3639
- }
3640
- /** True once {@link end} has been called — no further units are accepted. */
3641
- get isEnded() {
3642
- return this.ended;
3643
- }
3644
- subscribe(input) {
3645
- const sub = {
3646
- id: `fmp4-${randomUUID()}`,
3647
- tag: input.tag,
3648
- subscribedAt: Date.now(),
3649
- capacity: Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY),
3650
- queue: [],
3651
- delivered: 0,
3652
- closedReason: null,
3653
- wake: null,
3654
- iterating: false
3655
- };
3656
- this.subscriptions.set(sub.id, sub);
3657
- if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
3658
- if (this.ended) this.closeSubscription(sub, "ended");
3659
- this.logger?.info("fmp4 plane: subscribed", { meta: {
3660
- subscriptionId: sub.id,
3661
- tag: sub.tag,
3662
- hasRetainedInit: this.retainedInit !== null
3663
- } });
3664
- return this.facade(sub);
3665
- }
3666
- /**
3667
- * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
3668
- * emits exactly one per child, and a second means the child was respawned, in
3669
- * which case the old one describes a stream that no longer exists.
3670
- */
3671
- publish(unit) {
3672
- if (this.ended) return;
3673
- if (unit.kind === "init") this.retainedInit = unit;
3674
- for (const sub of this.subscriptions.values()) {
3675
- if (sub.closedReason !== null) continue;
3676
- this.enqueue(sub, unit);
3677
- }
3678
- }
3679
- /**
3680
- * The producer stopped. Every subscriber drains what it holds; its final
3681
- * packet carries `isLast`, and its generator then completes.
3682
- */
3683
- end(reason = "producer ended") {
3684
- if (this.ended) return;
3685
- this.ended = true;
3686
- this.logger?.info("fmp4 plane: ended", { meta: {
3687
- reason,
3688
- subscribers: this.subscriptions.size
3689
- } });
3690
- for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
3691
- }
3692
- listSubscribers() {
3693
- return [...this.subscriptions.values()].map((s) => ({
3694
- tag: s.tag,
3695
- subscribedAt: s.subscribedAt,
3696
- delivered: s.delivered,
3697
- closedReason: s.closedReason
3698
- }));
3699
- }
3700
- /** End and forget everything. Idempotent. */
3701
- dispose() {
3702
- this.end("disposed");
3703
- this.subscriptions.clear();
3704
- this.retainedInit = null;
3705
- }
3706
- enqueue(sub, unit) {
3707
- if (sub.queue.length >= sub.capacity) {
3708
- this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
3709
- subscriptionId: sub.id,
3710
- tag: sub.tag,
3711
- capacity: sub.capacity,
3712
- delivered: sub.delivered
3713
- } });
3714
- this.closeSubscription(sub, "slow-consumer");
3715
- return;
3716
- }
3717
- sub.queue.push({
3718
- kind: unit.kind,
3719
- data: unit.data,
3720
- sequence: unit.sequence,
3721
- isLast: false
3816
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
3817
+ return;
3818
+ }
3819
+ if (!isCurrent()) return;
3820
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
3821
+ this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
3822
+ tags: { deviceId: this.args.deviceId },
3823
+ meta: {
3824
+ sourceId: this.args.sourceId,
3825
+ code,
3826
+ signal,
3827
+ unitsOut: this.unitsOut
3828
+ }
3829
+ });
3830
+ this.args.plane.end("the fragment child exited");
3831
+ this.deps.onChildExit?.(error);
3832
+ });
3722
3833
  });
3723
- this.wake(sub);
3724
- }
3725
- closeSubscription(sub, reason) {
3726
- if (sub.closedReason !== null) return;
3727
- sub.closedReason = reason;
3728
- if (reason === "slow-consumer") sub.queue.length = 0;
3729
- this.wake(sub);
3730
- }
3731
- wake(sub) {
3732
- const resume = sub.wake;
3733
- sub.wake = null;
3734
- resume?.();
3735
3834
  }
3736
- facade(sub) {
3737
- const plane = this;
3738
- return {
3739
- id: sub.id,
3740
- tag: sub.tag,
3741
- get delivered() {
3742
- return sub.delivered;
3743
- },
3744
- get closedReason() {
3745
- return sub.closedReason;
3746
- },
3747
- packets: () => plane.iterate(sub),
3748
- release: () => {
3749
- plane.closeSubscription(sub, "released");
3750
- plane.subscriptions.delete(sub.id);
3835
+ /**
3836
+ * The byte stream stopped being splittable. Not recoverable — the splitter
3837
+ * cannot resynchronise mid-box — so the child is a corpse and every consumer
3838
+ * has to be told, loudly, with the reason.
3839
+ */
3840
+ onFault(reason, wasLive, current, settle) {
3841
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
3842
+ this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
3843
+ tags: { deviceId: this.args.deviceId },
3844
+ meta: {
3845
+ sourceId: this.args.sourceId,
3846
+ unitsOut: this.unitsOut,
3847
+ interstitial: this.splitter.discardedInterstitialTypes,
3848
+ reason
3751
3849
  }
3752
- };
3850
+ });
3851
+ this.killChild();
3852
+ settle(error);
3853
+ if (wasLive && current) {
3854
+ this.args.plane.end("the fragment child produced unsplittable output");
3855
+ this.deps.onChildExit?.(error);
3856
+ }
3753
3857
  }
3754
- async *iterate(sub) {
3755
- if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
3756
- sub.iterating = true;
3757
- for (;;) {
3758
- const next = sub.queue.shift();
3759
- if (next === void 0) {
3760
- if (sub.closedReason !== null) return;
3761
- await new Promise((resolve) => {
3762
- sub.wake = resolve;
3763
- });
3764
- continue;
3765
- }
3766
- const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
3767
- sub.delivered += 1;
3768
- yield {
3769
- ...next,
3770
- isLast
3771
- };
3772
- if (isLast) return;
3858
+ killChild() {
3859
+ const child = this.child;
3860
+ this.child = null;
3861
+ if (child && !child.killed) try {
3862
+ child.kill("SIGTERM");
3863
+ } catch (err) {
3864
+ this.deps.logger.warn("fmp4 fragment child: kill error", {
3865
+ tags: { deviceId: this.args.deviceId },
3866
+ meta: {
3867
+ sourceId: this.args.sourceId,
3868
+ error: errMsg(err)
3869
+ }
3870
+ });
3773
3871
  }
3774
3872
  }
3775
3873
  };
@@ -4397,6 +4495,7 @@ var EgressTranscodeManager = class {
4397
4495
  }
4398
4496
  const actualHwAccel = child.decodeHwAccel();
4399
4497
  if (decodeHwAccel !== null && !isSoftwareDecode(decodeHwAccel) && actualHwAccel === null) this.opts.onDecodeHwAccelFailed?.(decodeHwAccel);
4498
+ else if (actualHwAccel !== null && !isSoftwareDecode(actualHwAccel)) this.opts.onDecodeHwAccelSucceeded?.(actualHwAccel);
4400
4499
  const entry = {
4401
4500
  key,
4402
4501
  deviceId,
@@ -4599,7 +4698,7 @@ var EgressTranscodeManager = class {
4599
4698
  *
4600
4699
  * The child owns the software retry and the liveness reporting itself, in the
4601
4700
  * same shape as the other two transports; this wrapper is the adapter, not
4602
- * the logic. See `egress-fragment-child.ts`.
4701
+ * the logic. See the primitive's `ffmpeg/fmp4-fragment-child.ts`.
4603
4702
  */
4604
4703
  createFragmentChild(spec, fragments) {
4605
4704
  const child = new Fmp4FragmentChild({
@@ -4609,7 +4708,7 @@ var EgressTranscodeManager = class {
4609
4708
  ...this.opts.fragmentFirstUnitTimeoutMs === void 0 ? {} : { firstUnitTimeoutMs: this.opts.fragmentFirstUnitTimeoutMs },
4610
4709
  ...spec.onDied ? { onChildExit: spec.onDied } : {}
4611
4710
  }, {
4612
- brokerId: spec.brokerId,
4711
+ sourceId: spec.brokerId,
4613
4712
  deviceId: spec.deviceId,
4614
4713
  fragmentMs: fragments.fragmentMs,
4615
4714
  invocation: spec.invocation,
@@ -9858,6 +9957,29 @@ var StreamBroker = class StreamBroker {
9858
9957
  * right model based on per-camera codec + sample rate.
9859
9958
  */
9860
9959
  audioTrackInfo = null;
9960
+ /**
9961
+ * BROKER-SIDE AUDIO MUTE (D83) — operator policy, not a stream fact.
9962
+ *
9963
+ * While true this broker distributes NO audio on any plane it serves. It is
9964
+ * applied at the three points audio ENTERS the broker (`onAudioRtp`,
9965
+ * `pushEncodedPacket`) and re-checked at the single point it LEAVES
9966
+ * (`fanOutEncoded`), so a packet already inside a decode session when the
9967
+ * mute lands cannot escape.
9968
+ *
9969
+ * Deliberately NOT cleared by `resetStreamState` / a re-dial: it is device
9970
+ * policy that outlives any one connection. `StreamBrokerManager` re-applies
9971
+ * it to every freshly created broker for the same reason.
9972
+ */
9973
+ audioMuted = false;
9974
+ /** Audio units dropped by the mute since the current dial. Surfaced in stats. */
9975
+ audioMutedDropped = 0;
9976
+ /**
9977
+ * One-shot per dial: the mute's drop is announced the FIRST time it actually
9978
+ * drops something, not once per packet. A broker created while already muted
9979
+ * has no transition to log, and a silent drop reads as "the camera has no
9980
+ * microphone" — which is the confusion this whole row exists to remove.
9981
+ */
9982
+ audioMutedDropLogged = false;
9861
9983
  reconnectTimer;
9862
9984
  placeholderTimer;
9863
9985
  reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS;
@@ -10409,6 +10531,69 @@ var StreamBroker = class StreamBroker {
10409
10531
  this.logger?.warn("source-restart observer threw", { meta: { error: errMsg(err) } });
10410
10532
  }
10411
10533
  }
10534
+ /**
10535
+ * Turn this broker's audio plane off (or back on).
10536
+ *
10537
+ * Idempotent: setting the value it already holds does nothing at all — in
10538
+ * particular it does NOT churn the RTSP sessions, so the manager can call
10539
+ * this on every broker creation and every reconcile pass without cost.
10540
+ *
10541
+ * A real transition closes the restreamer's audio-bearing pull sessions.
10542
+ * That is not incidental: the recorder pulls this restreamer with `-map 0`,
10543
+ * so its ffmpeg maps whatever the SDP declared at CONNECT time. Dropping the
10544
+ * RTP while leaving an `m=audio` in its SDP would leave it waiting on an
10545
+ * audio stream that never delivers (the mp4 muxer holds video up to
10546
+ * `max_interleave_delta`). Re-pulling is what makes the recording honestly
10547
+ * audio-less rather than audio-declared-and-starved. The recorder's
10548
+ * `SegmentWriter` respawns on exit with bounded backoff, so the cost is one
10549
+ * segment boundary per flip.
10550
+ */
10551
+ setAudioMuted(muted) {
10552
+ if (this.audioMuted === muted) return;
10553
+ this.audioMuted = muted;
10554
+ this.audioMutedDropped = 0;
10555
+ this.audioMutedDropLogged = false;
10556
+ const closedPulls = this.rtspRestreamer.setAudioMuted(muted);
10557
+ this.logger?.info(muted ? "audio plane MUTED broker-side — live and recording go silent" : "audio plane UNMUTED broker-side — audio distribution resumes", {
10558
+ tags: { deviceId: this.numericDeviceId },
10559
+ meta: {
10560
+ brokerId: this.deviceId,
10561
+ audioCodec: this.audioCodec,
10562
+ hasAudioTrack: this.audioTrackInfo !== null,
10563
+ closedPulls
10564
+ }
10565
+ });
10566
+ }
10567
+ /** The broker's own audio-plane policy. `true` = distributing no audio. */
10568
+ isAudioMuted() {
10569
+ return this.audioMuted;
10570
+ }
10571
+ /**
10572
+ * True when the current connection is muxing audio at all — the OBSERVED
10573
+ * half of the D83 audio-plane reconcile. Read off the SDP/track snapshot
10574
+ * rather than a packet counter so a camera that negotiated an audio track
10575
+ * and has simply been quiet does not read as a missing mux.
10576
+ */
10577
+ hasAudioTrack() {
10578
+ return this.audioTrackInfo !== null;
10579
+ }
10580
+ /**
10581
+ * Account for one audio unit the mute threw away, announcing the FIRST one
10582
+ * per dial. Never per-packet: at 50 pps a line each would be its own outage.
10583
+ */
10584
+ dropMutedAudio(where) {
10585
+ this.audioMutedDropped += 1;
10586
+ if (this.audioMutedDropLogged) return;
10587
+ this.audioMutedDropLogged = true;
10588
+ this.logger?.info("audio muted broker-side — dropping this camera’s audio plane", {
10589
+ tags: { deviceId: this.numericDeviceId },
10590
+ meta: {
10591
+ brokerId: this.deviceId,
10592
+ where,
10593
+ audioCodec: this.audioCodec
10594
+ }
10595
+ });
10596
+ }
10412
10597
  /** Set whether pre-buffer counts as demand. When false and no other consumers, the broker suspends. */
10413
10598
  setPreBufferEnabled(enabled) {
10414
10599
  this._preBufferEnabled = enabled;
@@ -10681,6 +10866,10 @@ var StreamBroker = class StreamBroker {
10681
10866
  }
10682
10867
  this.resetPushStallTimer();
10683
10868
  }
10869
+ if (packet.type === "audio" && this.audioMuted) {
10870
+ this.dropMutedAudio("push-encoded");
10871
+ return;
10872
+ }
10684
10873
  if (packet.type === "audio" && this.source?.type === "push" && this.audioCodecSession) {
10685
10874
  const codec = packet.codec.toLowerCase();
10686
10875
  if (codec !== "pcmu" && codec !== "pcma") {
@@ -10961,6 +11150,10 @@ var StreamBroker = class StreamBroker {
10961
11150
  * safe one.
10962
11151
  */
10963
11152
  fanOutEncoded(packet) {
11153
+ if (packet.type === "audio" && this.audioMuted) {
11154
+ this.dropMutedAudio("encoded-fanout");
11155
+ return;
11156
+ }
10964
11157
  for (const [cb, entry] of this.encodedCallbacks) try {
10965
11158
  cb(packet);
10966
11159
  entry.packetsDelivered += 1;
@@ -11239,7 +11432,9 @@ var StreamBroker = class StreamBroker {
11239
11432
  preBufferMs: this.preBuffer.getBufferedDurationMs(),
11240
11433
  preBufferPackets: this.preBuffer.getPacketCount(),
11241
11434
  decoderNodeId: this.frameHandlePlane?.decoderNodeIds()[0] ?? null,
11242
- audio: this.audioTrackInfo ?? null
11435
+ audio: this.audioTrackInfo ?? null,
11436
+ audioMuted: this.audioMuted,
11437
+ audioMutedDropped: this.audioMutedDropped
11243
11438
  };
11244
11439
  }
11245
11440
  /**
@@ -11674,6 +11869,10 @@ var StreamBroker = class StreamBroker {
11674
11869
  onAudioRtp: (rtpData) => {
11675
11870
  if (this.stopping) return;
11676
11871
  this.kickRtpActivityWatchdog();
11872
+ if (this.audioMuted) {
11873
+ this.dropMutedAudio("rtsp-audio-rtp");
11874
+ return;
11875
+ }
11677
11876
  this.audioRtpSeen++;
11678
11877
  if (this.audioRtpSeen === 1 || this.audioRtpSeen === 50) this.logger?.info("audio: rtp packet received", { meta: {
11679
11878
  seq: this.audioRtpSeen,
@@ -12336,6 +12535,79 @@ function hexToBytes(hex) {
12336
12535
  }
12337
12536
  return out;
12338
12537
  }
12538
+ /**
12539
+ * Minimum gap between two re-dials of the same device. A re-dial costs a
12540
+ * ~2 s video interruption on every source of the camera; at the 30 s
12541
+ * reconcile cadence, an un-cooled loop against a camera whose audio genuinely
12542
+ * cannot come back would be a permanent stutter.
12543
+ */
12544
+ var AUDIO_PLANE_REDIAL_COOLDOWN_MS = 5 * 6e4;
12545
+ var EMPTY_AUDIO_PLANE_STATE = {
12546
+ disagreements: 0,
12547
+ redials: 0,
12548
+ lastRedialMs: 0,
12549
+ gaveUp: false
12550
+ };
12551
+ /**
12552
+ * Decide what one pass should do. Pure: no clock, no I/O, no broker.
12553
+ *
12554
+ * `nowMs` is passed in rather than read so the cooldown is testable without
12555
+ * waiting five minutes for it.
12556
+ */
12557
+ function decideAudioPlaneAction(observation, state, nowMs) {
12558
+ switch (observation.kind) {
12559
+ case "broker-muted":
12560
+ case "no-live-source":
12561
+ case "audio-present":
12562
+ case "declared-off": return {
12563
+ action: { kind: "none" },
12564
+ nextState: EMPTY_AUDIO_PLANE_STATE
12565
+ };
12566
+ case "read-failed":
12567
+ case "audio-unknown": return {
12568
+ action: { kind: "none" },
12569
+ nextState: state
12570
+ };
12571
+ case "declared-on": {
12572
+ if (state.gaveUp) return {
12573
+ action: { kind: "none" },
12574
+ nextState: state
12575
+ };
12576
+ const disagreements = state.disagreements + 1;
12577
+ if (disagreements < 2) return {
12578
+ action: { kind: "await-second-pass" },
12579
+ nextState: {
12580
+ ...state,
12581
+ disagreements
12582
+ }
12583
+ };
12584
+ if (state.redials >= 3) return {
12585
+ action: { kind: "give-up" },
12586
+ nextState: {
12587
+ ...state,
12588
+ disagreements,
12589
+ gaveUp: true
12590
+ }
12591
+ };
12592
+ if (state.lastRedialMs > 0 && nowMs - state.lastRedialMs < 3e5) return {
12593
+ action: { kind: "cooldown" },
12594
+ nextState: {
12595
+ ...state,
12596
+ disagreements: 2
12597
+ }
12598
+ };
12599
+ return {
12600
+ action: { kind: "redial" },
12601
+ nextState: {
12602
+ disagreements: 0,
12603
+ redials: state.redials + 1,
12604
+ lastRedialMs: nowMs,
12605
+ gaveUp: false
12606
+ }
12607
+ };
12608
+ }
12609
+ }
12610
+ }
12339
12611
  //#endregion
12340
12612
  //#region src/stream-broker/stream-broker/transcode-pipeline.ts
12341
12613
  /**
@@ -12820,12 +13092,31 @@ var DeviceOverrideSchema = object({
12820
13092
  preBuffer: record(string(), StreamPreBufferSchema).optional(),
12821
13093
  joinBurstMaxSpanMs: record(string(), number()).optional(),
12822
13094
  streamingDebug: boolean().optional(),
13095
+ audioMuted: boolean().optional(),
12823
13096
  webrtcDebug: boolean().optional(),
12824
13097
  derivedStreams: record(string(), DerivedStreamSchema).optional(),
12825
13098
  hwaccel: record(string(), FfmpegHwAccelSettingSchema).optional(),
12826
13099
  sourceParser: record(string(), SourceParserSettingSchema).optional(),
12827
13100
  sourceParserArgs: record(string(), SourceParserArgsEntrySchema).optional()
12828
13101
  });
13102
+ /**
13103
+ * Read `audioEnabled` off a `privacyMask.getStatus` payload (D83 reconcile).
13104
+ *
13105
+ * `getStatus` is the one cap method whose output is the generic device-status
13106
+ * shape rather than the cap's own schema, so it arrives untyped — this is the
13107
+ * documented type boundary, and a guard rather than a cast.
13108
+ *
13109
+ * `null` means UNKNOWN, and unknown must never become "audio is on": that is
13110
+ * the read that would re-dial a healthy stream on the strength of a payload
13111
+ * we could not parse. The camera says `null` itself when it has no microphone
13112
+ * control (`supportsAudioMute === false`), which lands in the same bucket for
13113
+ * the same reason.
13114
+ */
13115
+ function readDeclaredAudioEnabled(v) {
13116
+ if (v === null || typeof v !== "object") return null;
13117
+ const raw = { ...v }["audioEnabled"];
13118
+ return typeof raw === "boolean" ? raw : null;
13119
+ }
12829
13120
  /** Whole-blob schema for the persisted `deviceOverrides` store key:
12830
13121
  * numeric-string deviceId → DeviceOverride (includes `derivedStreams`). */
12831
13122
  var DeviceOverridesMapSchema = record(string(), DeviceOverrideSchema);
@@ -12949,6 +13240,11 @@ var StreamBrokerManager = class StreamBrokerManager {
12949
13240
  /** Per-device overrides (pre-buffer + legacy). */
12950
13241
  deviceOverrides = /* @__PURE__ */ new Map();
12951
13242
  /**
13243
+ * D83 audio-plane reconcile memory, per device. Absent = agreeing; entries
13244
+ * are deleted the moment a pass agrees, so a healthy fleet holds none.
13245
+ */
13246
+ audioPlaneState = /* @__PURE__ */ new Map();
13247
+ /**
12952
13248
  * `deviceId:camStreamId` keys for which the `native-udp` → `native-tcp`
12953
13249
  * fallback warning has already been logged, so the warn fires once per stream
12954
13250
  * rather than on every source (re)build / dial.
@@ -13156,6 +13452,15 @@ var StreamBrokerManager = class StreamBrokerManager {
13156
13452
  */
13157
13453
  egressHwAccelCache = createHwAccelCache({ ttlMs: 6e4 });
13158
13454
  /**
13455
+ * Backends that could not create a device in THIS process. Separate from the
13456
+ * memo above on purpose: the memo answers "what should decode this" on a read
13457
+ * cadence and expires in a minute, so folding the failure into it meant the
13458
+ * next cold child one minute later re-paid the same ~3 s death — measured
13459
+ * seven times on 615 between 22:17 and 22:52 on 2026-08-07. See
13460
+ * `dead-decode-backends.ts`.
13461
+ */
13462
+ deadDecodeBackends = createDeadDecodeBackends();
13463
+ /**
13159
13464
  * Snapshot of the system `ffmpeg` config section (binaryPath / hwAccel /
13160
13465
  * threadCount). Refreshed by the host addon via `setFfmpegConfig`; the
13161
13466
  * transcode manager reads it live per spawn. Defaults are safe (PATH ffmpeg,
@@ -13355,8 +13660,17 @@ var StreamBrokerManager = class StreamBrokerManager {
13355
13660
  };
13356
13661
  },
13357
13662
  onDecodeHwAccelFailed: (backend) => {
13358
- this.egressHwAccelCache.write(null);
13359
- this.logger.warn("egress transcode: decode backend FAILED in this process — software until the memo expires", { meta: { backend } });
13663
+ const verdict = this.deadDecodeBackends.markDead(backend);
13664
+ this.logger.warn("egress transcode: decode backend FAILED in this process — QUARANTINED, software until it is re-probed", { meta: {
13665
+ backend,
13666
+ failures: verdict.failures,
13667
+ quarantineMs: verdict.quarantineMs
13668
+ } });
13669
+ },
13670
+ onDecodeHwAccelSucceeded: (backend) => {
13671
+ if (this.deadDecodeBackends.failuresOf(backend) === 0) return;
13672
+ this.deadDecodeBackends.markAlive(backend);
13673
+ this.logger.info("egress transcode: decode backend RECOVERED in this process — the quarantine is lifted", { meta: { backend } });
13360
13674
  }
13361
13675
  });
13362
13676
  }
@@ -13379,6 +13693,22 @@ var StreamBrokerManager = class StreamBrokerManager {
13379
13693
  * hub).
13380
13694
  */
13381
13695
  async resolveEgressHwAccel(deviceId, override) {
13696
+ const resolved = await resolveWithQuarantine({
13697
+ resolve: () => this.readEgressHwAccel(deviceId, override),
13698
+ dead: this.deadDecodeBackends
13699
+ });
13700
+ if (resolved.veto !== null) this.logger.warn("egress transcode: decoding in SOFTWARE — the backend is QUARANTINED after failing in this process", {
13701
+ tags: { deviceId },
13702
+ meta: {
13703
+ backend: resolved.veto.backend,
13704
+ failures: resolved.veto.failures,
13705
+ remainingMs: resolved.veto.remainingMs
13706
+ }
13707
+ });
13708
+ return resolved.backend;
13709
+ }
13710
+ /** The decoder addon's answer for this node — before the process quarantine. */
13711
+ async readEgressHwAccel(deviceId, override) {
13382
13712
  const decoderApi = this.buildDecoderCapApi(deviceId);
13383
13713
  return resolveEgressDecodeHwAccel({
13384
13714
  readDecoderBackend: async () => {
@@ -14296,7 +14626,96 @@ var StreamBrokerManager = class StreamBrokerManager {
14296
14626
  this.disabledDevices.add(d.id);
14297
14627
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
14298
14628
  } else this.disabledDevices.delete(d.id);
14299
- await Promise.allSettled(cameras.filter((d) => !d.disabled).map((d) => this.reconcileDeviceCatalog(d.id)));
14629
+ const live = cameras.filter((d) => !d.disabled);
14630
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id)));
14631
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
14632
+ const liveIds = new Set(live.map((d) => d.id));
14633
+ for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
14634
+ }
14635
+ /**
14636
+ * ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
14637
+ * for the decision rules; this half only gathers and acts.
14638
+ *
14639
+ * Every branch that changes nothing still says so at debug — the whole
14640
+ * failure this repairs was invisible, and a reconcile whose "I looked and
14641
+ * everything was fine" is unobservable is a reconcile nobody can trust
14642
+ * after the next incident.
14643
+ */
14644
+ async reconcileDeviceAudioPlane(deviceId) {
14645
+ const observation = await this.observeAudioPlane(deviceId);
14646
+ const { action, nextState } = decideAudioPlaneAction(observation, this.audioPlaneState.get(deviceId) ?? EMPTY_AUDIO_PLANE_STATE, Date.now());
14647
+ if (nextState === EMPTY_AUDIO_PLANE_STATE) this.audioPlaneState.delete(deviceId);
14648
+ else this.audioPlaneState.set(deviceId, nextState);
14649
+ const meta = {
14650
+ observed: observation.kind,
14651
+ disagreements: nextState.disagreements,
14652
+ redials: nextState.redials
14653
+ };
14654
+ switch (action.kind) {
14655
+ case "none": return;
14656
+ case "await-second-pass":
14657
+ this.logger.info("audio-plane reconcile: camera declares audio but no broker carries a track — confirming on the next pass", {
14658
+ tags: { deviceId },
14659
+ meta
14660
+ });
14661
+ return;
14662
+ case "cooldown":
14663
+ this.logger.debug("audio-plane reconcile: disagreement held — re-dial cooling down", {
14664
+ tags: { deviceId },
14665
+ meta: {
14666
+ ...meta,
14667
+ cooldownMs: AUDIO_PLANE_REDIAL_COOLDOWN_MS
14668
+ }
14669
+ });
14670
+ return;
14671
+ case "give-up":
14672
+ this.logger.warn("audio-plane reconcile: giving up — the camera declares audio and re-dialing has not brought a track back", {
14673
+ tags: { deviceId },
14674
+ meta: {
14675
+ ...meta,
14676
+ maxRedials: 3
14677
+ }
14678
+ });
14679
+ return;
14680
+ case "redial":
14681
+ this.logger.info("audio-plane reconcile: audio declared but absent on every source — re-dialing", {
14682
+ tags: { deviceId },
14683
+ meta
14684
+ });
14685
+ this.redialDeviceSources(deviceId, "audio-plane-reconcile");
14686
+ return;
14687
+ }
14688
+ }
14689
+ /**
14690
+ * The gather half. Cheap questions first: the expensive device read happens
14691
+ * ONLY when the free in-memory answer already looks wrong.
14692
+ */
14693
+ async observeAudioPlane(deviceId) {
14694
+ if (this.deviceOverrides.get(deviceId)?.audioMuted === true) return { kind: "broker-muted" };
14695
+ const streams = this.cameraStreams.get(deviceId);
14696
+ if (!streams) return { kind: "no-live-source" };
14697
+ const sources = [];
14698
+ for (const [camStreamId, entry] of streams) {
14699
+ if (entry.kind === "derived") continue;
14700
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
14701
+ if (broker && broker.status === "streaming") sources.push(broker);
14702
+ }
14703
+ if (sources.length === 0) return { kind: "no-live-source" };
14704
+ if (sources.some((b) => b.hasAudioTrack())) return { kind: "audio-present" };
14705
+ if (!this.api) return { kind: "read-failed" };
14706
+ let status;
14707
+ try {
14708
+ status = await this.api.privacyMask.getStatus.query({ deviceId });
14709
+ } catch (err) {
14710
+ this.logger.debug("audio-plane reconcile: device audio read failed — changing nothing", {
14711
+ tags: { deviceId },
14712
+ meta: { error: errMsg(err) }
14713
+ });
14714
+ return { kind: "read-failed" };
14715
+ }
14716
+ const declared = readDeclaredAudioEnabled(status);
14717
+ if (declared === null) return { kind: "audio-unknown" };
14718
+ return declared ? { kind: "declared-on" } : { kind: "declared-off" };
14300
14719
  }
14301
14720
  /** Release every live broker of a disabled device (idempotent — later poll
14302
14721
  * ticks find none and no-op). `logTransition` marks the disable EDGE. */
@@ -16255,6 +16674,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16255
16674
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
16256
16675
  broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
16257
16676
  this.applyPreBufferConfig(broker, deviceId, camStreamId);
16677
+ this.applyAudioMuteConfig(broker, deviceId);
16258
16678
  this.applyJoinBurstBound(broker, deviceId, camStreamId);
16259
16679
  this.applyClipRetention(deviceId);
16260
16680
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
@@ -16450,6 +16870,72 @@ var StreamBrokerManager = class StreamBrokerManager {
16450
16870
  broker.setPreBufferEnabled(defaultWarm);
16451
16871
  broker.setPreBufferDuration(defaultWarm ? this.defaultPreBufferSec : 0);
16452
16872
  }
16873
+ /** Push the persisted mute onto a broker. Idempotent — safe on every pass. */
16874
+ applyAudioMuteConfig(broker, deviceId) {
16875
+ broker.setAudioMuted(this.deviceOverrides.get(deviceId)?.audioMuted === true);
16876
+ }
16877
+ /** Every live broker of a device, derived and source alike. */
16878
+ brokersOfDevice(deviceId) {
16879
+ const streams = this.cameraStreams.get(deviceId);
16880
+ if (!streams) return [];
16881
+ const found = [];
16882
+ for (const camStreamId of streams.keys()) {
16883
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
16884
+ if (broker) found.push(broker);
16885
+ }
16886
+ return found;
16887
+ }
16888
+ /**
16889
+ * `streamBroker.getDeviceAudioMute` — read the device's audio-plane policy.
16890
+ *
16891
+ * The STORE is the answer, not the brokers: the policy applies to brokers
16892
+ * that do not exist yet, so a camera with nothing dialled is still muted.
16893
+ * `appliedBrokers` reports how much of it is presently in force, and is
16894
+ * diagnostic only — a switch that read it as the state would flip itself
16895
+ * off every time the camera went idle.
16896
+ */
16897
+ getDeviceAudioMute(input) {
16898
+ return {
16899
+ muted: this.deviceOverrides.get(input.deviceId)?.audioMuted === true,
16900
+ appliedBrokers: this.brokersOfDevice(input.deviceId).filter((b) => b.isAudioMuted()).length
16901
+ };
16902
+ }
16903
+ /**
16904
+ * Set the device's audio-plane policy: persist it, then apply it to every
16905
+ * live broker — including DERIVED ones, whose restreamer would otherwise
16906
+ * keep serving an `m=audio` grafted from its transcode egress.
16907
+ *
16908
+ * Persist BEFORE applying. If the process dies between the two, the next
16909
+ * boot re-applies from the store and the camera is silent as asked; the
16910
+ * other order loses the operator's decision and the camera is audible again
16911
+ * with the switch still reading off.
16912
+ */
16913
+ async setDeviceAudioMute(input) {
16914
+ const { deviceId, muted } = input;
16915
+ const prior = this.deviceOverrides.get(deviceId)?.audioMuted === true;
16916
+ const next = {
16917
+ ...this.deviceOverrides.get(deviceId),
16918
+ audioMuted: muted
16919
+ };
16920
+ this.deviceOverrides.set(deviceId, next);
16921
+ this.deviceOverridePersister?.(this.deviceOverrides);
16922
+ const brokers = this.brokersOfDevice(deviceId);
16923
+ for (const broker of brokers) broker.setAudioMuted(muted);
16924
+ if (prior !== muted) {
16925
+ this.logger.info("broker audio mute set", {
16926
+ tags: { deviceId },
16927
+ meta: {
16928
+ muted,
16929
+ brokers: brokers.length
16930
+ }
16931
+ });
16932
+ this.audioPlaneState.delete(deviceId);
16933
+ }
16934
+ return Promise.resolve({
16935
+ muted,
16936
+ appliedBrokers: brokers.filter((b) => b.isAudioMuted()).length
16937
+ });
16938
+ }
16453
16939
  async fanOutRegistration(_deviceId, brokerId, _cam, broker) {
16454
16940
  if (this.webrtcServer) this.webrtcServer.registerBroker(brokerId, broker);
16455
16941
  }
@@ -17931,6 +18417,17 @@ var AdaptiveSession = class AdaptiveSession {
17931
18417
  acquireTranscodeEgress;
17932
18418
  /** The acquired egress, while this session holds a reference to it. */
17933
18419
  transcodeEgressLeg = null;
18420
+ /**
18421
+ * An acquire started at the ANSWER and not yet consumed by the feed.
18422
+ *
18423
+ * Read-and-null-out is the whole ownership protocol: whichever of
18424
+ * `startTranscodeFeed` and `releaseTranscodeEgress` reads a non-null value
18425
+ * owns the release, and since both read it synchronously exactly one can.
18426
+ * Without that, a session closing while its prewarm is in flight either
18427
+ * double-releases (the manager logs it as a consumer bookkeeping bug) or
18428
+ * leaks a child the primitive has no TTL to reclaim.
18429
+ */
18430
+ transcodeEgressPending = null;
17934
18431
  /** Unsubscribe from the acquired egress's video plane. */
17935
18432
  detachTranscodeEgress = null;
17936
18433
  /** Codec actually negotiated with the browser after SDP answer. */
@@ -18570,6 +19067,7 @@ var AdaptiveSession = class AdaptiveSession {
18570
19067
  negotiated: this.negotiatedCodec,
18571
19068
  needsTranscode: this.needsTranscode
18572
19069
  } });
19070
+ this.prewarmTranscodeEgress();
18573
19071
  if (this.debug) this.logger.info("Codec negotiation detail", { meta: {
18574
19072
  phase: "session",
18575
19073
  sessionId: this.sessionId,
@@ -18768,6 +19266,7 @@ var AdaptiveSession = class AdaptiveSession {
18768
19266
  setRemoteMs,
18769
19267
  createAnswerMs
18770
19268
  } });
19269
+ this.prewarmTranscodeEgress();
18771
19270
  if (this.debug) this.logger.info("Codec negotiation detail (client-offer)", { meta: {
18772
19271
  phase: "session",
18773
19272
  sessionId: this.sessionId,
@@ -19488,6 +19987,51 @@ var AdaptiveSession = class AdaptiveSession {
19488
19987
  * unchanged — the request asks for `audio: 'passthrough'` precisely so the
19489
19988
  * child does not encode a plane this session never reads.
19490
19989
  */
19990
+ /**
19991
+ * Start the egress acquire NOW, in parallel with ICE and DTLS.
19992
+ *
19993
+ * `needsTranscode` is final the moment the answer's codec is known — the
19994
+ * "Codec negotiated" line already prints it — but the acquire used to happen
19995
+ * inside `startFeedingFrames`, which runs only after the DTLS wait. On camera
19996
+ * 615 that put the leg ~2 s after session creation and the first packet ~5 s
19997
+ * after that, against a browser watchdog that re-offers every ~5 s: the viewer
19998
+ * was always gone before the media existed, and the next session paid for a
19999
+ * cold child again. Three minutes of that on 2026-08-07 22:17–22:20.
20000
+ *
20001
+ * Only the ffmpeg moves. The ATTACH stays behind DTLS, because `sendRtp`
20002
+ * silently drops before the transport is connected.
20003
+ *
20004
+ * Idempotent and safe to call from any negotiation path; a session that needs
20005
+ * no transcode does nothing. Public for the same reason
20006
+ * {@link armSourceSilenceWatchdog} is — the acquire/release contract is
20007
+ * testable without standing up werift.
20008
+ */
20009
+ prewarmTranscodeEgress() {
20010
+ if (this.closed) return;
20011
+ if (!this.needsTranscode) return;
20012
+ if (this.transcodeEgressPending !== null || this.transcodeEgressLeg !== null) return;
20013
+ const acquire = this.acquireTranscodeEgress;
20014
+ if (acquire === null) return;
20015
+ this.logger.info("Transcode leg: prewarming the egress while ICE/DTLS negotiate", { meta: {
20016
+ phase: "session",
20017
+ sessionId: this.sessionId,
20018
+ deviceId: this.deviceId,
20019
+ sourceCodec: this.sourceCodec
20020
+ } });
20021
+ this.transcodeEgressPending = acquire({
20022
+ profile: this.transcodeProfile,
20023
+ sessionId: this.sessionId
20024
+ });
20025
+ }
20026
+ /**
20027
+ * Take ownership of the in-flight prewarm, if there is one. Nulls the field
20028
+ * synchronously so the release path can never also claim it — see the field.
20029
+ */
20030
+ claimPrewarmedEgress() {
20031
+ const pending = this.transcodeEgressPending;
20032
+ this.transcodeEgressPending = null;
20033
+ return pending;
20034
+ }
19491
20035
  startTranscodeFeed(signal) {
19492
20036
  const acquire = this.acquireTranscodeEgress;
19493
20037
  if (acquire === null) {
@@ -19499,20 +20043,32 @@ var AdaptiveSession = class AdaptiveSession {
19499
20043
  if (!this.closed) this.close();
19500
20044
  return;
19501
20045
  }
20046
+ const prewarmed = this.claimPrewarmedEgress();
19502
20047
  this.logger.info(`Starting ${this.sourceCodec}→H.264 Baseline transcode leg`, { meta: {
19503
20048
  phase: "session",
19504
20049
  sessionId: this.sessionId,
19505
20050
  deviceId: this.deviceId,
19506
20051
  sourceCodec: this.sourceCodec,
19507
- mode: "egress-primitive"
20052
+ mode: "egress-primitive",
20053
+ prewarmed: prewarmed !== null
19508
20054
  } });
20055
+ const acquireNow = () => acquire({
20056
+ profile: this.transcodeProfile,
20057
+ sessionId: this.sessionId
20058
+ });
19509
20059
  (async () => {
19510
20060
  let leg;
19511
20061
  try {
19512
- leg = await acquire({
19513
- profile: this.transcodeProfile,
19514
- sessionId: this.sessionId
19515
- });
20062
+ leg = await (prewarmed === null ? acquireNow() : prewarmed.catch((err) => {
20063
+ if (signal.aborted || this.closed) throw err;
20064
+ this.logger.warn("Transcode leg: the prewarmed acquire failed — retrying now that the feed has started", { meta: {
20065
+ phase: "session",
20066
+ sessionId: this.sessionId,
20067
+ deviceId: this.deviceId,
20068
+ error: errMsg(err)
20069
+ } });
20070
+ return acquireNow();
20071
+ }));
19516
20072
  } catch (err) {
19517
20073
  this.logger.error("Transcode feed: acquiring the egress transcode FAILED", { meta: {
19518
20074
  phase: "session",
@@ -19580,6 +20136,14 @@ var AdaptiveSession = class AdaptiveSession {
19580
20136
  try {
19581
20137
  detach?.();
19582
20138
  } catch {}
20139
+ const orphan = this.claimPrewarmedEgress();
20140
+ if (orphan !== null) orphan.then((prewarmedLeg) => prewarmedLeg.release()).catch((err) => {
20141
+ this.logger.debug("Transcode leg: the unclaimed prewarm ended without a handle", { meta: {
20142
+ phase: "session",
20143
+ sessionId: this.sessionId,
20144
+ error: errMsg(err)
20145
+ } });
20146
+ });
19583
20147
  const leg = this.transcodeEgressLeg;
19584
20148
  this.transcodeEgressLeg = null;
19585
20149
  if (!leg) return;