@camstack/addon-pipeline 1.2.307 → 1.2.309

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 (33) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-DgdiQtQb.js → default-detection-model-BZMS04Wn.js} +1 -1
  4. package/dist/{default-detection-model-BV9bIuOo.mjs → default-detection-model-ClQ1gcZ9.mjs} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-DdVMFlJ6.mjs → dist-B3_sbBDM.mjs} +305 -48
  8. package/dist/{dist-CyJWk-_f.js → dist-BLf0YjAa.js} +310 -47
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-COu_tHy4.mjs → node-DJFKTJ0B.mjs} +1 -53
  12. package/dist/{node-HsWG5fGp.js → node-Vwf2PuIT.js} +1 -53
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-Bz3oIMzl.mjs → process-memory-BvwauBlX.mjs} +1 -1
  16. package/dist/{process-memory-TQXG7qrJ.js → process-memory-DGTVVYlX.js} +1 -1
  17. package/dist/recorder/index.js +147 -22
  18. package/dist/recorder/index.mjs +147 -22
  19. package/dist/{segment-demux-js-BrKa7tpr.js → segment-demux-js-BUAAuSVF.js} +22 -4
  20. package/dist/{segment-demux-js-CEJn4BR0.mjs → segment-demux-js-C0oz7IVm.mjs} +17 -5
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DdYUVQSA.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-7H0Oxf23.mjs} +2 -2
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C6EaWavG.mjs +26 -0
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BhO-szf8.mjs +26 -0
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-D--a9rVM.mjs → hostInit-DEY-Dqqo.mjs} +2 -2
  28. package/dist/stream-broker/index.js +434 -111
  29. package/dist/stream-broker/index.mjs +434 -111
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DPHv0v3Q.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CdKbuxqG.mjs +0 -26
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { An as array, Bn as union, Cn as makeSourceBrokerId, En as parseProfileBrokerId, Fn as number, Ft as rankStreams, H as addonWidgetsSourceCapability, Ht as resolveEgressDecodeHwAccel, In as object, K as batteryCapability, M as RATE_CONTROL_TIGHT, Mn as discriminatedUnion, Pn as literal, Q as declareLogChannel, R as RingBuffer, Rn as record, Sn as makeProfileBrokerId, St as maskUrlCredentials, Vn as EventCategory, X as createHwAccelCache, Xt as streamBrokerCapability, Y as clipExportFailureOf, Z as createLogChannelsProvider, a as BatteryStatusSchema, at as egressTransportFromRequest, bt as logChannelsCapability, cn as DeviceFeature, dn as BOOT_RECOVERY_BACKOFF_MS, en as webrtcSessionCapability, et as deriveBatteryPresence, fn as BaseAddon, ft as ffmpegRate, gn as createEvent, ht as gifFromMp4Args, in as buildFfmpegArgs$1, it as egressTranscodeSharingKey, j as RATE_CONTROL_RELAXED, jn as boolean, kn as _enum, ln as DeviceType, mn as CamProfileSchema, mt as gifDeriveSpeed, o as CLIP_BROKER_PACED_RATES, on as invocationFromEncodeProfile, pn as CAM_PROFILE_ORDER, q as cameraStreamsCapability, s as CLIP_STILL_REASON_CODE_HEADER, sn as isSoftwareDecode, tn as AUDIO_PRESETS, un as errMsg, w as G711_SCALE_CORRECTION_DB, wn as nodePin, x as EncodeProfileSchema, xn as isEvent, yn as expandAudioChunkToF32le, yt as loadContributionCapability, zn as string } from "../dist-DdVMFlJ6.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-COu_tHy4.mjs";
2
+ import { An as _enum, Bn as string, Cn as makeProfileBrokerId, Dn as parseProfileBrokerId, Fn as literal, H as addonWidgetsSourceCapability, Hn as EventCategory, In as number, It as rankStreams, K as batteryCapability, Ln as object, M as RATE_CONTROL_TIGHT, Mn as boolean, Nn as discriminatedUnion, Q as declareLogChannel, R as RingBuffer, Sn as isEvent, St as maskUrlCredentials, Tn as nodePin, Tt as memoizePinnedFfmpeg, Ut as resolveEgressDecodeHwAccel, Vn as union, X as createHwAccelCache, Y as clipExportFailureOf, Z as createLogChannelsProvider, Zt as streamBrokerCapability, _n as createEvent, a as BatteryStatusSchema, an as buildFfmpegArgs$1, at as egressTransportFromRequest, bn as expandAudioChunkToF32le, bt as logChannelsCapability, cn as isSoftwareDecode, dn as errMsg, et as deriveBatteryPresence, fn as BOOT_RECOVERY_BACKOFF_MS, ft as ffmpegRate, hn as CamProfileSchema, ht as gifFromMp4Args, it as egressTranscodeSharingKey, j as RATE_CONTROL_RELAXED, jn as array, ln as DeviceFeature, mn as CAM_PROFILE_ORDER, mt as gifDeriveSpeed, nn as AUDIO_PRESETS, o as CLIP_BROKER_PACED_RATES, pn as BaseAddon, q as cameraStreamsCapability, s as CLIP_STILL_REASON_CODE_HEADER, sn as invocationFromEncodeProfile, tn as webrtcSessionCapability, un as DeviceType, w as G711_SCALE_CORRECTION_DB, wn as makeSourceBrokerId, x as EncodeProfileSchema, yt as loadContributionCapability, zn as record } from "../dist-B3_sbBDM.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-DJFKTJ0B.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-CEJn4BR0.mjs";
7
+ import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-C0oz7IVm.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -99,6 +99,86 @@ function createClientLogIngest(onEntries) {
99
99
  });
100
100
  };
101
101
  }
102
+ var FfmpegReresolver = class {
103
+ deps;
104
+ inFlight = null;
105
+ lastAttemptAt = null;
106
+ constructor(deps) {
107
+ this.deps = deps;
108
+ }
109
+ /**
110
+ * Ask again, unless an attempt is running or one started inside the bound.
111
+ * Never throws; the outcome is logged either way.
112
+ */
113
+ request(reason) {
114
+ if (this.inFlight !== null) return "in-flight";
115
+ const now = (this.deps.now ?? Date.now)();
116
+ const minIntervalMs = this.deps.minIntervalMs ?? 3e4;
117
+ if (this.lastAttemptAt !== null && now - this.lastAttemptAt < minIntervalMs) return "too-soon";
118
+ this.lastAttemptAt = now;
119
+ this.deps.logger.warn("stream-broker: re-resolving the pinned ffmpeg after a refusal (D540)", { meta: { reason } });
120
+ this.inFlight = this.attempt().finally(() => {
121
+ this.inFlight = null;
122
+ });
123
+ return "started";
124
+ }
125
+ /** The attempt in flight, for a caller (or a spec) that wants to wait. */
126
+ settled() {
127
+ return this.inFlight ?? Promise.resolve();
128
+ }
129
+ async attempt() {
130
+ try {
131
+ const binaryPath = await this.deps.resolve();
132
+ this.deps.apply(binaryPath);
133
+ this.deps.logger.info("stream-broker: pinned ffmpeg resolved on retry — children may spawn", { meta: { binaryPath } });
134
+ } catch (err) {
135
+ this.deps.logger.error("stream-broker: pinned ffmpeg still unavailable — every ffmpeg child keeps refusing", { meta: { error: err instanceof Error ? err.message : String(err) } });
136
+ }
137
+ }
138
+ };
139
+ /**
140
+ * The broker addon's whole ffmpeg wiring, in one place a spec can drive: the
141
+ * boot-time resolution, the refusal handler on the manager, and the
142
+ * re-resolve that hands a later success back to the manager's config — which
143
+ * every live broker reads per dial.
144
+ */
145
+ function wireBrokerFfmpeg(deps) {
146
+ const give = (binaryPath) => {
147
+ deps.host.setFfmpegConfig({
148
+ binaryPath,
149
+ hwAccel: deps.hwAccel(),
150
+ threadCount: 0
151
+ });
152
+ deps.onBinaryPath?.(binaryPath);
153
+ };
154
+ const reresolver = new FfmpegReresolver({
155
+ resolve: deps.resolve,
156
+ apply: give,
157
+ logger: deps.logger,
158
+ ...deps.now !== void 0 ? { now: deps.now } : {},
159
+ ...deps.minIntervalMs !== void 0 ? { minIntervalMs: deps.minIntervalMs } : {}
160
+ });
161
+ deps.host.setFfmpegMissingHandler(() => {
162
+ reresolver.request("a broker ffmpeg child was refused");
163
+ });
164
+ return {
165
+ load: async () => {
166
+ try {
167
+ const binaryPath = await deps.resolve();
168
+ give(binaryPath);
169
+ deps.logger.info("Loaded ffmpeg config", { meta: {
170
+ binaryPath,
171
+ hwAccel: deps.hwAccel()
172
+ } });
173
+ } catch (err) {
174
+ give(null);
175
+ deps.logger.error("stream-broker: could not resolve the pinned ffmpeg — every ffmpeg child on this node refuses until this is fixed", { meta: { error: err instanceof Error ? err.message : String(err) } });
176
+ }
177
+ },
178
+ request: (reason) => reresolver.request(reason),
179
+ settled: () => reresolver.settled()
180
+ };
181
+ }
102
182
  //#endregion
103
183
  //#region src/stream-broker/log-channels.ts
104
184
  /**
@@ -5449,6 +5529,16 @@ function classifyDecodeHwAccel(setting) {
5449
5529
  prefer: setting
5450
5530
  };
5451
5531
  }
5532
+ /**
5533
+ * The binary to spawn, or a THROW after an ERROR naming the camera and the
5534
+ * child that was refused. Never a PATH name (D540).
5535
+ */
5536
+ function requireBrokerFfmpeg(binaryPath, ask) {
5537
+ if (binaryPath !== null) return binaryPath;
5538
+ ask.logger.error(`${ask.purpose} refused — no pinned ffmpeg on this node (D540)`, { ...ask.deviceId !== null ? { tags: { deviceId: ask.deviceId } } : {} });
5539
+ ask.onMissing?.();
5540
+ throw new Error(`${ask.purpose}: no pinned ffmpeg resolved on this node`);
5541
+ }
5452
5542
  /** Narrow an audio target to a sidecar-capable codec, or `null` (copy/none). */
5453
5543
  function toAudioSidecarCodec(audio) {
5454
5544
  return audio === "aac" || audio === "opus" || audio === "pcmu" ? audio : null;
@@ -6566,13 +6656,19 @@ var EgressTranscodeManager = class {
6566
6656
  }
6567
6657
  createChild(spec) {
6568
6658
  if (this.opts.createChild) return this.opts.createChild(spec);
6569
- if (spec.fragments) return this.createFragmentChild(spec, spec.fragments);
6570
- if (spec.pushSink) return this.createPushChild(spec, spec.pushSink);
6659
+ const ffmpegBinaryPath = requireBrokerFfmpeg(this.opts.ffmpegConfig().binaryPath, {
6660
+ logger: this.opts.logger,
6661
+ deviceId: spec.deviceId,
6662
+ purpose: "egress transcode",
6663
+ ...this.opts.onFfmpegMissing === void 0 ? {} : { onMissing: this.opts.onFfmpegMissing }
6664
+ });
6665
+ if (spec.fragments) return this.createFragmentChild(spec, spec.fragments, ffmpegBinaryPath);
6666
+ if (spec.pushSink) return this.createPushChild(spec, spec.pushSink, ffmpegBinaryPath);
6571
6667
  const channel = spec.channel;
6572
6668
  if (channel === void 0) throw new Error("egress transcode: a dial child was created without a channel");
6573
6669
  const egress = new TranscodeEgress({
6574
6670
  logger: this.opts.logger.child("egress"),
6575
- ffmpegBinaryPath: this.opts.ffmpegConfig().binaryPath,
6671
+ ffmpegBinaryPath,
6576
6672
  spawnFn: this.opts.spawnFn ?? spawn,
6577
6673
  acquireChannel: () => channel,
6578
6674
  ...spec.onDied ? { onChildExit: spec.onDied } : {},
@@ -6604,10 +6700,10 @@ var EgressTranscodeManager = class {
6604
6700
  * same shape as the other two transports; this wrapper is the adapter, not
6605
6701
  * the logic. See the primitive's `ffmpeg/fmp4-fragment-child.ts`.
6606
6702
  */
6607
- createFragmentChild(spec, fragments) {
6703
+ createFragmentChild(spec, fragments, ffmpegBinaryPath) {
6608
6704
  const child = new Fmp4FragmentChild({
6609
6705
  logger: this.opts.logger.child("egress-fmp4"),
6610
- ffmpegBinaryPath: this.opts.ffmpegConfig().binaryPath,
6706
+ ffmpegBinaryPath,
6611
6707
  spawnFn: this.opts.spawnFn ?? spawn,
6612
6708
  ...this.opts.fragmentFirstUnitTimeoutMs === void 0 ? {} : { firstUnitTimeoutMs: this.opts.fragmentFirstUnitTimeoutMs },
6613
6709
  ...spec.onDied ? { onChildExit: spec.onDied } : {}
@@ -6657,7 +6753,7 @@ var EgressTranscodeManager = class {
6657
6753
  * masked the same failure on the dial path with its own software retry, which
6658
6754
  * is why only this transport was dark.
6659
6755
  */
6660
- createPushChild(spec, sink) {
6756
+ createPushChild(spec, sink, ffmpegBinaryPath) {
6661
6757
  const logger = this.opts.logger.child("egress-push");
6662
6758
  const deviceId = spec.deviceId;
6663
6759
  const timeoutMs = this.opts.pushFirstPacketTimeoutMs ?? DEFAULT_PUSH_FIRST_PACKET_TIMEOUT_MS;
@@ -6675,7 +6771,7 @@ var EgressTranscodeManager = class {
6675
6771
  const makeReader = (input) => {
6676
6772
  if (this.opts.createPushReader) return this.opts.createPushReader(input);
6677
6773
  return new FfmpegPushReader({
6678
- ffmpegPath: this.opts.ffmpegConfig().binaryPath,
6774
+ ffmpegPath: ffmpegBinaryPath,
6679
6775
  args: [...input.args],
6680
6776
  logger,
6681
6777
  ...this.opts.spawnFn ? { spawnFn: this.opts.spawnFn } : {}
@@ -6702,7 +6798,7 @@ var EgressTranscodeManager = class {
6702
6798
  meta: {
6703
6799
  brokerId: spec.brokerId,
6704
6800
  camStreamId: spec.publishedCamStreamId,
6705
- ffmpegPath: this.opts.ffmpegConfig().binaryPath,
6801
+ ffmpegPath: ffmpegBinaryPath,
6706
6802
  decodeHwAccel: decodeHwAccel ?? "software",
6707
6803
  argv: args.join(" ")
6708
6804
  }
@@ -7121,7 +7217,7 @@ async function gifFromMp4(input) {
7121
7217
  }
7122
7218
  function runGif(input, args) {
7123
7219
  return new Promise((resolve, reject) => {
7124
- const proc = input.spawnFn(input.ffmpegPath ?? "ffmpeg", [...args], { stdio: [
7220
+ const proc = input.spawnFn(input.ffmpegPath, [...args], { stdio: [
7125
7221
  "pipe",
7126
7222
  "pipe",
7127
7223
  "pipe"
@@ -7985,7 +8081,7 @@ async function renderPreBufferClip(input) {
7985
8081
  hevc,
7986
8082
  sourceWidth: input.sourceWidth ?? 0
7987
8083
  });
7988
- const proc = input.spawn(input.ffmpegPath ?? "ffmpeg", clipFfmpegArgs({
8084
+ const proc = input.spawn(input.ffmpegPath, clipFfmpegArgs({
7989
8085
  ...input,
7990
8086
  hevc,
7991
8087
  copyVideo
@@ -12802,11 +12898,12 @@ var StreamBroker = class StreamBroker {
12802
12898
  /** Active "ffmpeg parser" ingress reader when `source.ffmpegParser` is set. */
12803
12899
  ffmpegSourceReader = null;
12804
12900
  /**
12805
- * Resolved `ffmpeg` binary path for the ffmpeg-parser ingress, threaded from
12806
- * the manager's system-config (`ffmpeg.binaryPath`). Defaults to bare
12807
- * `'ffmpeg'` (PATH lookup) for tests / stand-alone callers.
12901
+ * Where the ffmpeg-parser ingress reads the node's pinned ffmpeg — the
12902
+ * manager's LIVE config, asked at each dial rather than copied at creation,
12903
+ * so a broker created while a re-resolve was in flight still sees its result.
12904
+ * `null` — the ingress REFUSES by name; there is no PATH default (D540).
12808
12905
  */
12809
- ffmpegBinaryPath = "ffmpeg";
12906
+ ffmpegBinaryPathSource = () => null;
12810
12907
  rtpDepacketizer = null;
12811
12908
  /** Flag-only depacketizer for the `push-rtp` source-RTP pre-buffer path
12812
12909
  * (sets keyframe/param-set flags without re-emitting AnnexB packets). */
@@ -13389,12 +13486,21 @@ var StreamBroker = class StreamBroker {
13389
13486
  this.restreamUrlResolver = resolver;
13390
13487
  }
13391
13488
  /**
13392
- * Inject the resolved `ffmpeg` binary path used by the "ffmpeg parser"
13393
- * ingress ({@link startFfmpegSourceReader}). Threaded from the manager's
13394
- * system-config (`ffmpeg.binaryPath`). Absent = bare `'ffmpeg'` (PATH lookup).
13489
+ * Inject where the "ffmpeg parser" ingress ({@link startFfmpegSourceReader})
13490
+ * reads the node's pinned ffmpeg: the manager's live config, asked per dial.
13395
13491
  */
13396
- setFfmpegBinaryPath(binaryPath) {
13397
- this.ffmpegBinaryPath = binaryPath;
13492
+ setFfmpegBinaryPathSource(source) {
13493
+ this.ffmpegBinaryPathSource = source;
13494
+ }
13495
+ /** The pinned ffmpeg this broker would spawn right now (`null` = none). */
13496
+ getFfmpegBinaryPath() {
13497
+ return this.ffmpegBinaryPathSource();
13498
+ }
13499
+ /** Told when the ingress was refused for want of the pinned ffmpeg. */
13500
+ ffmpegMissingHandler = null;
13501
+ /** Wire the manager's re-resolve request. */
13502
+ setFfmpegMissingHandler(handler) {
13503
+ this.ffmpegMissingHandler = handler;
13398
13504
  }
13399
13505
  /**
13400
13506
  * Inject the addon's child-cost registry. Injected rather than constructed
@@ -15320,18 +15426,28 @@ var StreamBroker = class StreamBroker {
15320
15426
  transport: source.ffmpegParser?.transport ?? "tcp"
15321
15427
  } });
15322
15428
  this.destroyFfmpegSourceReader();
15429
+ const ffmpegPath = this.ffmpegBinaryPathSource();
15430
+ if (ffmpegPath === null) {
15431
+ this.logger?.error("ffmpeg source reader refused — no pinned ffmpeg on this node (D540)", {
15432
+ ...this.numericDeviceId === void 0 ? {} : { tags: { deviceId: this.numericDeviceId } },
15433
+ meta: { url: maskUrlCredentials(source.url) }
15434
+ });
15435
+ this._status = "error";
15436
+ this.ffmpegMissingHandler?.();
15437
+ this.scheduleReconnect();
15438
+ return;
15439
+ }
15323
15440
  const parser = source.ffmpegParser;
15324
15441
  const sourceKind = source.type === "rtmp" ? "rtmp" : source.type === "flv" || source.type === "http" ? "http" : "rtsp";
15325
- const args = buildFfmpegSourceReaderArgs({
15326
- sourceUrl: source.url,
15327
- sourceKind,
15328
- transport: parser?.transport ?? "tcp",
15329
- ...parser?.inputArgs ? { inputArgs: parser.inputArgs } : {},
15330
- ...parser?.outputArgs ? { outputArgs: parser.outputArgs } : {}
15331
- });
15332
15442
  const reader = new FfmpegSourceReader({
15333
- ffmpegPath: this.ffmpegBinaryPath,
15334
- args,
15443
+ ffmpegPath,
15444
+ args: buildFfmpegSourceReaderArgs({
15445
+ sourceUrl: source.url,
15446
+ sourceKind,
15447
+ transport: parser?.transport ?? "tcp",
15448
+ ...parser?.inputArgs ? { inputArgs: parser.inputArgs } : {},
15449
+ ...parser?.outputArgs ? { outputArgs: parser.outputArgs } : {}
15450
+ }),
15335
15451
  ...this.logger ? { logger: this.logger.child("ffmpeg-source") } : {},
15336
15452
  claimCost: (pid) => this.claimIngestCost(pid)
15337
15453
  }, {
@@ -16307,7 +16423,7 @@ function hexToBytes(hex) {
16307
16423
  * pipe is the pattern proven by the WebRTC transcode feed.
16308
16424
  */
16309
16425
  var DEFAULT_FFMPEG_CONFIG = {
16310
- binaryPath: "ffmpeg",
16426
+ binaryPath: null,
16311
16427
  hwAccel: "copy",
16312
16428
  threadCount: 0
16313
16429
  };
@@ -16341,6 +16457,7 @@ var TranscodePipelineManager = class {
16341
16457
  resolveProfileSource;
16342
16458
  releaseGraceMs;
16343
16459
  ffmpegConfig;
16460
+ onFfmpegMissing;
16344
16461
  resolveStreamHwAccel;
16345
16462
  spawnFn;
16346
16463
  acquireChannel;
@@ -16353,6 +16470,7 @@ var TranscodePipelineManager = class {
16353
16470
  this.resolveProfileSource = opts.resolveProfileSource ?? null;
16354
16471
  this.releaseGraceMs = opts.releaseGraceMs ?? 5e3;
16355
16472
  this.ffmpegConfig = opts.ffmpegConfig ?? (() => DEFAULT_FFMPEG_CONFIG);
16473
+ this.onFfmpegMissing = opts.onFfmpegMissing ?? null;
16356
16474
  this.probeFfmpeg = opts.probeFfmpeg ?? probeFfmpegBinary;
16357
16475
  this.resolveStreamHwAccel = opts.resolveStreamHwAccel ?? null;
16358
16476
  this.spawnFn = opts.spawnFn ?? spawn;
@@ -16425,11 +16543,17 @@ var TranscodePipelineManager = class {
16425
16543
  async startTranscode(opts) {
16426
16544
  if (!this.acquireChannel) throw new Error("getStreamWithCodec: transcode egress not wired (acquireChannel missing)");
16427
16545
  const cfg = this.ffmpegConfig();
16546
+ const ffmpegBinaryPath = requireBrokerFfmpeg(cfg.binaryPath, {
16547
+ logger: this.logger,
16548
+ deviceId: opts.deviceId,
16549
+ purpose: "transcode egress",
16550
+ ...this.onFfmpegMissing === null ? {} : { onMissing: this.onFfmpegMissing }
16551
+ });
16428
16552
  const effectiveHwAccel = this.effectiveHwAccelSetting(opts.deviceId, opts.camStreamId, cfg.hwAccel);
16429
16553
  const choice = chooseVideoEncoder({
16430
16554
  target: opts.targetCodec,
16431
16555
  backend: await this.resolveHardwareBackend(),
16432
- binaryEncoders: await this.resolveBinaryEncoders(cfg.binaryPath),
16556
+ binaryEncoders: await this.resolveBinaryEncoders(ffmpegBinaryPath),
16433
16557
  hwAccelSetting: effectiveHwAccel,
16434
16558
  renderNode: DEFAULT_VAAPI_RENDER_DEVICE
16435
16559
  });
@@ -16459,7 +16583,7 @@ var TranscodePipelineManager = class {
16459
16583
  const sidecarCodec = opts.audioEgress ? toAudioSidecarCodec(opts.audio) : null;
16460
16584
  const egress = new TranscodeEgress({
16461
16585
  logger: this.logger.child("egress"),
16462
- ffmpegBinaryPath: cfg.binaryPath,
16586
+ ffmpegBinaryPath,
16463
16587
  spawnFn: this.spawnFn,
16464
16588
  acquireChannel: this.acquireChannel
16465
16589
  }, {
@@ -16688,11 +16812,12 @@ var TranscodePipelineManager = class {
16688
16812
  try {
16689
16813
  const backend = rankDecodeHwAccels((await this.hwaccel.resolve(prefer)).preferred, [])[0] ?? null;
16690
16814
  if (backend === null) return null;
16691
- const caps = await this.resolveBinaryCapabilities(this.ffmpegConfig().binaryPath);
16815
+ const binaryPath = this.ffmpegConfig().binaryPath;
16816
+ const caps = binaryPath === null ? null : await this.resolveBinaryCapabilities(binaryPath);
16692
16817
  if (caps !== null && !caps.hwaccels.has(backend)) {
16693
16818
  this.logger.warn("this ffmpeg has no such decode backend compiled in — decoding in software instead of dying on a device that cannot exist", { meta: {
16694
16819
  backend,
16695
- ffmpeg: this.ffmpegConfig().binaryPath,
16820
+ ffmpeg: binaryPath,
16696
16821
  hwaccels: [...caps.hwaccels].join(",") || "none"
16697
16822
  } });
16698
16823
  return null;
@@ -16954,6 +17079,21 @@ var DeviceOverridesMapSchema = record(string(), DeviceOverrideSchema);
16954
17079
  * prune driven by a map that shrinks on every unregister.
16955
17080
  */
16956
17081
  var RtspEnabledSchema = record(string(), boolean());
17082
+ /**
17083
+ * How far a {@link StreamBrokerManager.retractDevice} goes.
17084
+ *
17085
+ * The two flags answer different questions and a permanent removal sets BOTH:
17086
+ * `preserveAssignments` is about the per-stream teardown (skip the
17087
+ * re-ranking churn as each stream disappears), `purgeAssignments` is about the
17088
+ * device's profile-slot ENTRY (drop it outright, and persist that).
17089
+ */
17090
+ /**
17091
+ * The departed-device sweep's second read must be at least this long after
17092
+ * the first mark (D685): one default catalog-poll interval. Two reads closer
17093
+ * than that — the startup pass's 1.5 s re-runs, two overlapping polls — are
17094
+ * one observation.
17095
+ */
17096
+ var DEPARTURE_CONFIRM_MIN_MS = 3e4;
16957
17097
  var PUSH_KINDS = new Set(["push-annexb"]);
16958
17098
  /** Walk a profile map and return the slot that points at `camStreamId`, or null. */
16959
17099
  function findProfileForStream(map, camStreamId) {
@@ -17153,6 +17293,13 @@ var StreamBrokerManager = class StreamBrokerManager {
17153
17293
  * outage that put it here.
17154
17294
  */
17155
17295
  catalogPullFailures = /* @__PURE__ */ new Map();
17296
+ /**
17297
+ * Devices with registered streams that a successful fleet read did not
17298
+ * list, and WHEN that read was (D685). A mark, not a verdict: the sweep
17299
+ * retracts only when a read at least {@link DEPARTURE_CONFIRM_MIN_MS} later
17300
+ * agrees, and a reappearance clears it (D49).
17301
+ */
17302
+ departureMarks = /* @__PURE__ */ new Map();
17156
17303
  eventBus = null;
17157
17304
  /**
17158
17305
  * Per-broker stream health flag set by the watchdog. `undefined`
@@ -17373,12 +17520,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17373
17520
  /**
17374
17521
  * Snapshot of the system `ffmpeg` config section (binaryPath / hwAccel /
17375
17522
  * threadCount). Refreshed by the host addon via `setFfmpegConfig`; the
17376
- * transcode manager reads it live per spawn. Defaults are safe (PATH ffmpeg,
17377
- * `'copy'` software decode, auto threads) until the addon loads the section —
17378
- * software decode never hard-fails on a fragile HEVC source.
17523
+ * transcode manager reads it live per spawn. Until the addon resolves the
17524
+ * node's pinned binary there is NONE (`binaryPath: null`) and every child
17525
+ * refuses by name — never a PATH `ffmpeg` (D540). `'copy'` software decode
17526
+ * and auto threads stay the defaults: software decode never hard-fails on a
17527
+ * fragile HEVC source.
17379
17528
  */
17380
17529
  ffmpegConfig = {
17381
- binaryPath: "ffmpeg",
17530
+ binaryPath: null,
17382
17531
  hwAccel: "copy",
17383
17532
  threadCount: 0
17384
17533
  };
@@ -17414,6 +17563,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17414
17563
  },
17415
17564
  resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
17416
17565
  ffmpegConfig: () => this.ffmpegConfig,
17566
+ onFfmpegMissing: () => this.reportFfmpegMissing(),
17417
17567
  resolveStreamHwAccel: (deviceId, camStreamId) => this.getStreamHwAccelOverride(deviceId, camStreamId),
17418
17568
  sentryOffered: async (deviceId) => sentryEligible({
17419
17569
  batteryOperated: await this.isBatteryOperated(deviceId),
@@ -17476,6 +17626,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17476
17626
  });
17477
17627
  },
17478
17628
  ffmpegConfig: () => this.ffmpegConfig,
17629
+ onFfmpegMissing: () => this.reportFfmpegMissing(),
17479
17630
  ...this.costRegistry === null ? {} : { costRegistry: this.costRegistry },
17480
17631
  resolveDecodeHwAccel: (deviceId, override) => this.resolveEgressHwAccel(deviceId, override),
17481
17632
  acquireChannel: (brokerId) => {
@@ -17851,11 +18002,35 @@ var StreamBrokerManager = class StreamBrokerManager {
17851
18002
  if (localNodeId !== void 0) this.localNodeId = localNodeId;
17852
18003
  if (hwaccel !== void 0) this.hwaccel = hwaccel;
17853
18004
  }
17854
- /** Update the system `ffmpeg` config snapshot used by the transcode egress. */
18005
+ /**
18006
+ * Update the node's ffmpeg config snapshot. Every broker READS it per dial
18007
+ * ({@link bindBrokerFfmpeg}), so a re-resolved binary reaches live brokers —
18008
+ * including one created while the re-resolve was in flight — with no copy to
18009
+ * fan out (D540).
18010
+ */
17855
18011
  setFfmpegConfig(cfg) {
17856
18012
  this.ffmpegConfig = cfg;
17857
18013
  }
17858
18014
  /**
18015
+ * Point a broker at the node's LIVE pinned ffmpeg and at the re-resolve
18016
+ * request, so its ffmpeg-parser ingress spawns the current binary and a
18017
+ * refusal asks for it again.
18018
+ */
18019
+ bindBrokerFfmpeg(broker) {
18020
+ broker.setFfmpegBinaryPathSource(() => this.ffmpegConfig.binaryPath);
18021
+ broker.setFfmpegMissingHandler(() => this.reportFfmpegMissing());
18022
+ }
18023
+ /** Who asks for the pinned binary again after a refusal. Set by the addon. */
18024
+ ffmpegMissingHandler = null;
18025
+ /** Wire the host's re-resolve (`FfmpegReresolver.request`). */
18026
+ setFfmpegMissingHandler(handler) {
18027
+ this.ffmpegMissingHandler = handler;
18028
+ }
18029
+ /** A child was refused for want of the pinned ffmpeg: ask the host again. */
18030
+ reportFfmpegMissing() {
18031
+ this.ffmpegMissingHandler?.();
18032
+ }
18033
+ /**
17859
18034
  * The decode `hwaccel` options surfaceable in a (future) UI: the always-valid
17860
18035
  * settings (`copy`, `auto`, `none`) plus the concrete hardware backends THIS
17861
18036
  * node can use, resolved from the LOCAL `ctx.kernel.hwaccel` (os.platform +
@@ -18196,7 +18371,12 @@ var StreamBrokerManager = class StreamBrokerManager {
18196
18371
  let annexB;
18197
18372
  try {
18198
18373
  annexB = await encodePrestreamIdr({
18199
- ffmpegPath: this.ffmpegConfig.binaryPath,
18374
+ ffmpegPath: requireBrokerFfmpeg(this.ffmpegConfig.binaryPath, {
18375
+ logger,
18376
+ deviceId,
18377
+ purpose: "sentry pre-stream encode",
18378
+ onMissing: () => this.reportFfmpegMissing()
18379
+ }),
18200
18380
  plan,
18201
18381
  jpeg: picture.jpeg,
18202
18382
  logger,
@@ -18987,6 +19167,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18987
19167
  this.logger.debug("reconcileAllCatalogs: deviceManager.listAll failed — will retry", { meta: { error: errMsg(err) } });
18988
19168
  return;
18989
19169
  }
19170
+ await this.sweepDepartedDevices(devices);
18990
19171
  const cameras = devices.filter((d) => d.isCamera || d.type === DeviceType.Camera);
18991
19172
  for (const d of cameras) if (d.disabled) {
18992
19173
  const firstSeen = !this.disabledDevices.has(d.id);
@@ -19002,6 +19183,71 @@ var StreamBrokerManager = class StreamBrokerManager {
19002
19183
  for (const deviceId of [...this.catalogPullFailures.keys()]) if (!liveIds.has(deviceId)) this.catalogPullFailures.delete(deviceId);
19003
19184
  }
19004
19185
  /**
19186
+ * Retract and purge a device that left the fleet WITHOUT a
19187
+ * `DeviceUnregistered` (D685).
19188
+ *
19189
+ * The event is the fast path and it may be dropped (D8/D11). Since D685 no
19190
+ * provider retracts on teardown (that hook also runs on every runner
19191
+ * restart), so a lost event used to leave the device's cam streams
19192
+ * registered — and `high` is default-warm, so its prebuffer hold kept
19193
+ * dialling the camera with no consumer at all, until the broker restarted.
19194
+ *
19195
+ * Destroying on a fallible read, so every way the read can be WRONG decides
19196
+ * nothing (D49): the caller has already returned on a failed read, an EMPTY
19197
+ * listing is device-restore not having run, and one absence only MARKS the
19198
+ * device — the retract needs the next successful read to agree. A device
19199
+ * that reappears in between is unmarked.
19200
+ */
19201
+ async sweepDepartedDevices(devices) {
19202
+ if (devices.length === 0) {
19203
+ if (this.departureMarks.size > 0) this.logger.debug("departed-device sweep skipped — device enumeration came back empty", { meta: { marked: [...this.departureMarks.keys()] } });
19204
+ return;
19205
+ }
19206
+ const listed = new Set(devices.map((d) => d.id));
19207
+ for (const deviceId of [...this.departureMarks.keys()]) {
19208
+ if (!this.cameraStreams.has(deviceId)) {
19209
+ this.departureMarks.delete(deviceId);
19210
+ this.logger.debug("departure mark dropped — the device has no streams any more", { tags: { deviceId } });
19211
+ continue;
19212
+ }
19213
+ if (listed.has(deviceId)) {
19214
+ this.departureMarks.delete(deviceId);
19215
+ this.logger.info("device is back in the fleet listing — departure sweep cancelled", { tags: { deviceId } });
19216
+ }
19217
+ }
19218
+ const nowMs = Date.now();
19219
+ for (const deviceId of [...this.cameraStreams.keys()]) {
19220
+ if (listed.has(deviceId)) continue;
19221
+ const markedAt = this.departureMarks.get(deviceId);
19222
+ if (markedAt === void 0) {
19223
+ this.departureMarks.set(deviceId, nowMs);
19224
+ this.logger.info("device absent from the fleet listing — its streams are swept if a later read agrees", {
19225
+ tags: { deviceId },
19226
+ meta: { confirmAfterMs: DEPARTURE_CONFIRM_MIN_MS }
19227
+ });
19228
+ continue;
19229
+ }
19230
+ if (nowMs - markedAt < DEPARTURE_CONFIRM_MIN_MS) continue;
19231
+ this.departureMarks.delete(deviceId);
19232
+ this.logger.warn("device left the fleet without DeviceUnregistered — swept", {
19233
+ tags: { deviceId },
19234
+ meta: {
19235
+ camStreamIds: [...this.cameraStreams.get(deviceId)?.keys() ?? []],
19236
+ absentForMs: nowMs - markedAt
19237
+ }
19238
+ });
19239
+ await this.retractDevice(deviceId, {
19240
+ preserveAssignments: true,
19241
+ purgeAssignments: true
19242
+ }).catch((err) => {
19243
+ this.logger.warn("departed-device sweep: retract failed — the next two polls try again", {
19244
+ tags: { deviceId },
19245
+ meta: { error: errMsg(err) }
19246
+ });
19247
+ });
19248
+ }
19249
+ }
19250
+ /**
19005
19251
  * ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
19006
19252
  * for the decision rules; this half only gathers and acts.
19007
19253
  *
@@ -19448,7 +19694,12 @@ var StreamBrokerManager = class StreamBrokerManager {
19448
19694
  height: scaledHeight(cut, clipWidth)
19449
19695
  });
19450
19696
  const gifSpeed = input.gifSpeed ?? 4;
19451
- if (input.kinds.includes("gif")) {
19697
+ const gifFfmpeg = this.ffmpegConfig.binaryPath;
19698
+ if (input.kinds.includes("gif") && gifFfmpeg === null) {
19699
+ this.logger?.error("event media gif refused — no pinned ffmpeg on this node (D540)", { tags: { deviceId: input.deviceId } });
19700
+ this.reportFfmpegMissing();
19701
+ }
19702
+ if (input.kinds.includes("gif") && gifFfmpeg !== null) {
19452
19703
  const gif = await gifFromMp4({
19453
19704
  mp4,
19454
19705
  deviceId: input.deviceId,
@@ -19457,7 +19708,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19457
19708
  speed: gifDeriveSpeed(gifSpeed, input.speed),
19458
19709
  ...this.logger !== void 0 ? { logger: this.logger } : {},
19459
19710
  spawnFn: spawn,
19460
- ...this.ffmpegConfig.binaryPath !== void 0 ? { ffmpegPath: this.ffmpegConfig.binaryPath } : {}
19711
+ ffmpegPath: gifFfmpeg
19461
19712
  });
19462
19713
  if (gif !== null) {
19463
19714
  const height = scaledHeight(cut, input.gifMaxWidth);
@@ -19626,7 +19877,12 @@ var StreamBrokerManager = class StreamBrokerManager {
19626
19877
  "pipe",
19627
19878
  "pipe"
19628
19879
  ] }),
19629
- ffmpegPath: this.ffmpegConfig.binaryPath,
19880
+ ffmpegPath: requireBrokerFfmpeg(this.ffmpegConfig.binaryPath, {
19881
+ logger: this.logger,
19882
+ deviceId: cut.deviceId,
19883
+ purpose: "pre-buffer clip render",
19884
+ onMissing: () => this.reportFfmpegMissing()
19885
+ }),
19630
19886
  sdpParameterSets: cut.broker.getSdpParameterSets()
19631
19887
  });
19632
19888
  this.logger?.info("pre-buffer clip rendered", {
@@ -21704,7 +21960,7 @@ var StreamBrokerManager = class StreamBrokerManager {
21704
21960
  const source = this.buildSourceFromCamStream(cam);
21705
21961
  const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api), this.localNodeId);
21706
21962
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
21707
- broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
21963
+ this.bindBrokerFfmpeg(broker);
21708
21964
  if (this.costRegistry !== null) broker.setCostRegistry(this.costRegistry);
21709
21965
  this.applyPrebufferConfig(broker, deviceId, camStreamId);
21710
21966
  this.applyAudioMuteConfig(broker, deviceId);
@@ -21882,7 +22138,12 @@ var StreamBrokerManager = class StreamBrokerManager {
21882
22138
  }
21883
22139
  });
21884
22140
  const feeder = new DerivedStreamFeeder({
21885
- ffmpegPath: this.ffmpegConfig.binaryPath,
22141
+ ffmpegPath: requireBrokerFfmpeg(this.ffmpegConfig.binaryPath, {
22142
+ logger: this.logger,
22143
+ deviceId,
22144
+ purpose: "derived transcode",
22145
+ onMissing: () => this.reportFfmpegMissing()
22146
+ }),
21886
22147
  args,
21887
22148
  logger: this.logger.child(`derived-ffmpeg:${camStreamId}`).withTags({ deviceId })
21888
22149
  }, {
@@ -33830,7 +34091,7 @@ var offThreadJsParse = (bytes) => {
33830
34091
  * Drop-in for the session `demuxVideo` hook: JS parse off the live loop,
33831
34092
  * ffmpeg subprocess fallback unchanged.
33832
34093
  */
33833
- async function demuxSegmentPreferJsOffThread(bytes, opts = {}) {
34094
+ async function demuxSegmentPreferJsOffThread(bytes, opts) {
33834
34095
  return demuxSegmentPreferJs(bytes, {
33835
34096
  ...opts,
33836
34097
  jsParse: offThreadJsParse
@@ -34015,9 +34276,9 @@ function sliceMuLawToPcmuFrames(mulaw) {
34015
34276
  * can't decode) — ffmpeg exits non-zero, which is tolerated as empty output.
34016
34277
  *
34017
34278
  * @param bytes - The full `.m4s` fragment.
34018
- * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
34279
+ * @param ffmpegPath - The node's pinned ffmpeg (D540). Required: no PATH default.
34019
34280
  */
34020
- async function demuxSegmentToPcmu(bytes, ffmpegPath = "ffmpeg") {
34281
+ async function demuxSegmentToPcmu(bytes, ffmpegPath) {
34021
34282
  const mulaw = await runFfmpegOnce({
34022
34283
  ffmpegPath,
34023
34284
  args: SEGMENT_AUDIO_FFMPEG_ARGS,
@@ -34149,15 +34410,19 @@ var SEGMENT_AUDIO_OPUS_FFMPEG_ARGS = [
34149
34410
  * best-effort and must never block recorded video. Mirrors the video-side
34150
34411
  * `demuxSegmentPreferJs`.
34151
34412
  */
34152
- async function demuxSegmentAudioOpusPreferJs(bytes, opts = {}) {
34413
+ async function demuxSegmentAudioOpusPreferJs(bytes, opts) {
34153
34414
  try {
34154
34415
  const direct = await demuxSegmentAudioOpusJs(bytes);
34155
34416
  if (direct) return direct;
34156
34417
  } catch {}
34418
+ if (opts.ffmpegPath === null) {
34419
+ opts.onFfmpegFallback?.("audio track is not Opus (legacy fragment) and the ffmpeg fallback is refused — no pinned ffmpeg on this node (D540)");
34420
+ return [];
34421
+ }
34157
34422
  opts.onFfmpegFallback?.("audio track is not Opus (legacy fragment)");
34158
34423
  try {
34159
34424
  const opusFmp4 = await runFfmpegOnce({
34160
- ffmpegPath: opts.ffmpegPath ?? "ffmpeg",
34425
+ ffmpegPath: opts.ffmpegPath,
34161
34426
  args: SEGMENT_AUDIO_OPUS_FFMPEG_ARGS,
34162
34427
  input: bytes,
34163
34428
  tolerateNonZeroExit: true
@@ -35219,12 +35484,25 @@ var BrokerWebrtcServer = class {
35219
35484
  session.onClosed = () => this.cleanupSession(sessionId);
35220
35485
  const locateRecorded = this.locateRecordedSegment;
35221
35486
  const readRecorded = this.readRecordedSegmentBytes;
35222
- const resolveFfmpeg = this.resolveFfmpegPath;
35487
+ const resolveFfmpegPath = this.resolveFfmpegPath;
35488
+ const resolveFfmpeg = () => resolveFfmpegPath?.() ?? null;
35489
+ let noPinnedFfmpegLogged = false;
35490
+ const reportNoPinnedFfmpeg = (what) => {
35491
+ if (noPinnedFfmpegLogged) return;
35492
+ noPinnedFfmpegLogged = true;
35493
+ sessionLogger.error("recorded playback: ffmpeg fallback refused — no pinned ffmpeg on this node (D540)", {
35494
+ tags: { deviceId },
35495
+ meta: {
35496
+ sessionId,
35497
+ what
35498
+ }
35499
+ });
35500
+ };
35223
35501
  const demuxTally = new DemuxPathTally();
35224
35502
  let audioOpusFallbackLogged = false;
35225
35503
  if (locateRecorded && readRecorded) {
35226
35504
  const demuxVideo = (b) => demuxSegmentPreferJsOffThread(b, {
35227
- ffmpegPath: resolveFfmpeg?.(),
35505
+ ffmpegPath: resolveFfmpeg(),
35228
35506
  onDemuxPath: (outcome) => {
35229
35507
  const report = demuxTally.record(outcome, Date.now());
35230
35508
  if (!report) return;
@@ -35239,6 +35517,9 @@ var BrokerWebrtcServer = class {
35239
35517
  }
35240
35518
  });
35241
35519
  }
35520
+ }).catch((err) => {
35521
+ if (err instanceof FfmpegFallbackRefusedError) reportNoPinnedFfmpeg("video");
35522
+ throw err;
35242
35523
  });
35243
35524
  const scrubCache = new ScrubFragmentCache();
35244
35525
  const initialLiveProfile = this.profileTierResolver?.(brokerId) ?? null;
@@ -35354,9 +35635,11 @@ var BrokerWebrtcServer = class {
35354
35635
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
35355
35636
  };
35356
35637
  const demuxAudio = opusSession ? async (b) => {
35638
+ const ffmpegPath = resolveFfmpeg();
35357
35639
  return (await demuxSegmentAudioOpusPreferJs(b, {
35358
- ffmpegPath: resolveFfmpeg?.(),
35640
+ ffmpegPath,
35359
35641
  onFfmpegFallback: (reason) => {
35642
+ if (ffmpegPath === null) reportNoPinnedFfmpeg("audio");
35360
35643
  if (audioOpusFallbackLogged) return;
35361
35644
  audioOpusFallbackLogged = true;
35362
35645
  sessionLogger.warn("recorded audio: JS Opus demux missed — ffmpeg AAC→Opus fallback (once/session)", { meta: {
@@ -35370,7 +35653,14 @@ var BrokerWebrtcServer = class {
35370
35653
  data: au.data,
35371
35654
  ptsMs: au.ptsMs
35372
35655
  }));
35373
- } : (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.());
35656
+ } : async (b) => {
35657
+ const ffmpegPath = resolveFfmpeg();
35658
+ if (ffmpegPath === null) {
35659
+ reportNoPinnedFfmpeg("audio");
35660
+ return [];
35661
+ }
35662
+ return await demuxSegmentToPcmu(b, ffmpegPath);
35663
+ };
35374
35664
  const clipSource = clip && readClip ? createClipFileSource({
35375
35665
  handle: clip,
35376
35666
  read: readClip,
@@ -36739,6 +37029,39 @@ var EventLoopStallMonitor = class {
36739
37029
  }
36740
37030
  };
36741
37031
  //#endregion
37032
+ //#region src/stream-broker/device-removal.ts
37033
+ /**
37034
+ * The broker's half of an operator's device DELETE.
37035
+ *
37036
+ * Since D685 this subscription is the fast path of a removal: providers no
37037
+ * longer retract their streams from `removeDevice()`, because that hook also
37038
+ * runs on every runner restart. The backstop for a dropped event is the
37039
+ * catalog poll's departed-device sweep (`StreamBrokerManager`,
37040
+ * `sweepDepartedDevices`). Extracted from `addon.ts` so the registration the
37041
+ * addon performs is the one a test drives.
37042
+ */
37043
+ /** A PERMANENT removal: retract every stream AND purge the slot assignments. */
37044
+ async function retractRemovedDevice(deviceId, deps) {
37045
+ try {
37046
+ await deps.retractDevice(deviceId, {
37047
+ preserveAssignments: true,
37048
+ purgeAssignments: true
37049
+ });
37050
+ } catch (err) {
37051
+ deps.logger.warn("device retract failed", {
37052
+ tags: { deviceId },
37053
+ meta: { error: errMsg(err) }
37054
+ });
37055
+ }
37056
+ }
37057
+ /** Retract a device the moment the device-manager says it was deleted. */
37058
+ function subscribeDeviceRemoval(subscribe, deps) {
37059
+ subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
37060
+ const deviceId = event.data["deviceId"];
37061
+ if (typeof deviceId === "number") retractRemovedDevice(deviceId, deps());
37062
+ });
37063
+ }
37064
+ //#endregion
36742
37065
  //#region src/stream-broker/addon.ts
36743
37066
  /**
36744
37067
  * Hydrate the persisted RTSP restream state. Tokens come from their own
@@ -36820,10 +37143,21 @@ var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
36820
37143
  var NETWORK_QUALITY_STATS_TIMEOUT_MS = 2e3;
36821
37144
  var StreamBrokerAddon = class extends BaseAddon {
36822
37145
  brokerManager = null;
36823
- /** ffmpeg binary path from the `ffmpeg` config section, mirrored here so the
36824
- * recorded-playback demuxers (segment-demux/segment-audio) spawn the same
36825
- * configured binary as the transcode egress. Kept in sync by loadFfmpegConfig. */
36826
- ffmpegBinaryPath = "ffmpeg";
37146
+ /** The node's pinned ffmpeg, mirrored here so the recorded-playback demuxers
37147
+ * (segment-demux/segment-audio) spawn the same binary as the transcode
37148
+ * egress. Kept in sync by the ffmpeg wiring. `null` = none could be resolved:
37149
+ * their ffmpeg fallbacks refuse by name — never a PATH `ffmpeg` (D540). */
37150
+ ffmpegBinaryPath = null;
37151
+ /** The node's pinned build, asked once on success (D540). A failure is not
37152
+ * remembered, so the re-resolve below can succeed later. */
37153
+ pinnedFfmpeg = memoizePinnedFfmpeg(() => this.ctx.deps.ensureFfmpeg());
37154
+ /**
37155
+ * The way back from a failed boot-time resolution: a refused child asks, and
37156
+ * ONE bounded re-resolve hands the binary to the manager config (which every
37157
+ * live broker reads per dial) and the demuxers. Without it one failed
37158
+ * download disabled every broker ffmpeg child until the runner restarted.
37159
+ */
37160
+ ffmpegWiring = null;
36827
37161
  /** Once-per-addon log guard for the readGopBytes → whole-segment downgrade. */
36828
37162
  gopReadFallbackLogged = false;
36829
37163
  metricsSnapshotTimer = null;
@@ -36884,36 +37218,7 @@ var StreamBrokerAddon = class extends BaseAddon {
36884
37218
  async resolveFfmpegBinaryPath() {
36885
37219
  const override = this.config.binaryPath;
36886
37220
  if (typeof override === "string" && override.trim().length > 0) return override;
36887
- return await this.ctx.deps.ensureFfmpeg();
36888
- }
36889
- /**
36890
- * Push THIS node's ffmpeg config into the broker — all node-local now, NOT the
36891
- * retired global `ffmpeg` section. `binaryPath` via {@link resolveFfmpegBinaryPath}
36892
- * (per-node override → ensureFfmpeg); `hwAccel` = the per-node `hwaccel`
36893
- * preference (default `'copy'` = software, the safe HEVC-compatible choice; the
36894
- * concrete backend is resolved node-locally by the egress transcode manager,
36895
- * which asks the DECODER ADDON — never the kernel resolver, whose qsv-first
36896
- * order is the known-bad one here). `threadCount` is always `0` (auto — ffmpeg uses the
36897
- * node's own cores; the manual knob was removed).
36898
- */
36899
- async loadFfmpegConfig() {
36900
- if (!this.brokerManager) return;
36901
- try {
36902
- const binaryPath = await this.resolveFfmpegBinaryPath();
36903
- const hwAccel = this.config.hwaccel;
36904
- this.brokerManager.setFfmpegConfig({
36905
- binaryPath,
36906
- hwAccel,
36907
- threadCount: 0
36908
- });
36909
- this.ffmpegBinaryPath = binaryPath;
36910
- this.ctx.logger.info("Loaded ffmpeg config", { meta: {
36911
- binaryPath,
36912
- hwAccel
36913
- } });
36914
- } catch (err) {
36915
- this.ctx.logger.error("stream-broker: could not resolve an ffmpeg binary — no transcode on this node can be trusted until this is fixed", { meta: { error: errMsg(err) } });
36916
- }
37221
+ return await this.pinnedFfmpeg();
36917
37222
  }
36918
37223
  async onInitialize() {
36919
37224
  this.ctx.logger.info("log channels declared", { meta: { channels: STREAM_BROKER_LOG_CHANNELS.map((c) => c.descriptor.name) } });
@@ -36943,7 +37248,16 @@ var StreamBrokerAddon = class extends BaseAddon {
36943
37248
  };
36944
37249
  this.brokerManager.setDeviceSignalReader(async (deviceId) => (await readSignals(deviceId)).map((s) => s.code));
36945
37250
  this.brokerManager.setDeviceSignalDetailReader(readSignals);
36946
- await this.loadFfmpegConfig();
37251
+ this.ffmpegWiring = wireBrokerFfmpeg({
37252
+ host: this.brokerManager,
37253
+ resolve: () => this.resolveFfmpegBinaryPath(),
37254
+ hwAccel: () => this.config.hwaccel,
37255
+ onBinaryPath: (binaryPath) => {
37256
+ this.ffmpegBinaryPath = binaryPath;
37257
+ },
37258
+ logger: this.ctx.logger
37259
+ });
37260
+ await this.ffmpegWiring.load();
36947
37261
  await this.declareRowCollections();
36948
37262
  await this.purgeFlattenedSettingsKeys();
36949
37263
  const rtspProvider = this.brokerManager.getRtspRestreamProvider();
@@ -37112,10 +37426,7 @@ var StreamBrokerAddon = class extends BaseAddon {
37112
37426
  const deviceId = event.data.deviceId;
37113
37427
  if (typeof deviceId === "number") reconcileDevice(deviceId, "device-registered");
37114
37428
  });
37115
- this.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
37116
- const deviceId = event.data.deviceId;
37117
- if (typeof deviceId === "number") this.handleDeviceRemoved(deviceId);
37118
- });
37429
+ subscribeDeviceRemoval((filter, handler) => this.subscribe(filter, handler), () => this.deviceRemovalDeps());
37119
37430
  this.subscribe({ category: EventCategory.StreamParamsChanged }, (event) => {
37120
37431
  const deviceId = event.data.deviceId;
37121
37432
  if (typeof deviceId !== "number") return;
@@ -37228,7 +37539,10 @@ var StreamBrokerAddon = class extends BaseAddon {
37228
37539
  };
37229
37540
  }
37230
37541
  },
37231
- resolveFfmpegPath: () => this.ffmpegBinaryPath
37542
+ resolveFfmpegPath: () => {
37543
+ if (this.ffmpegBinaryPath === null) this.ffmpegWiring?.request("a recorded-playback demux needed ffmpeg");
37544
+ return this.ffmpegBinaryPath;
37545
+ }
37232
37546
  });
37233
37547
  this.brokerManager.setWebrtcServer(webrtcServer);
37234
37548
  this.webrtcServer = webrtcServer;
@@ -37369,18 +37683,13 @@ var StreamBrokerAddon = class extends BaseAddon {
37369
37683
  * persisted `profileMap` blob, so it returns at every boot. Live ids
37370
37684
  * 3217/3218 outlived their cameras exactly that way.
37371
37685
  */
37372
- async handleDeviceRemoved(deviceId) {
37373
- try {
37374
- await this.brokerManager?.retractDevice(deviceId, {
37375
- preserveAssignments: true,
37376
- purgeAssignments: true
37377
- });
37378
- } catch (err) {
37379
- this.ctx.logger.warn("device retract failed", {
37380
- tags: { deviceId },
37381
- meta: { error: errMsg(err) }
37382
- });
37383
- }
37686
+ deviceRemovalDeps() {
37687
+ return {
37688
+ retractDevice: async (deviceId, opts) => {
37689
+ await this.brokerManager?.retractDevice(deviceId, opts);
37690
+ },
37691
+ logger: this.ctx.logger
37692
+ };
37384
37693
  }
37385
37694
  /**
37386
37695
  * Emit one `stream-broker.metrics-snapshot` event per active broker.
@@ -37638,6 +37947,8 @@ var FfmpegDecoderSession = class {
37638
37947
  outputBuffer = Buffer.alloc(0);
37639
37948
  destroyed = false;
37640
37949
  logger;
37950
+ /** The pinned ffmpeg to spawn (D540). Required: there is no PATH default. */
37951
+ ffmpegPath;
37641
37952
  cachedWidth = 0;
37642
37953
  cachedHeight = 0;
37643
37954
  inputPackets = 0;
@@ -37646,8 +37957,9 @@ var FfmpegDecoderSession = class {
37646
37957
  totalDecodeTimeMs = 0;
37647
37958
  decodeCount = 0;
37648
37959
  startTime = Date.now();
37649
- constructor(config, logger = noopLogger) {
37960
+ constructor(config, ffmpegPath, logger = noopLogger) {
37650
37961
  this.config = { ...config };
37962
+ this.ffmpegPath = ffmpegPath;
37651
37963
  this.logger = logger;
37652
37964
  this.frameDropper = new FrameDropper(config.maxFps);
37653
37965
  this.spawnFfmpeg();
@@ -37658,7 +37970,8 @@ var FfmpegDecoderSession = class {
37658
37970
  this.outputBuffer = Buffer.alloc(0);
37659
37971
  this.cachedWidth = 0;
37660
37972
  this.cachedHeight = 0;
37661
- const child = spawn("ffmpeg", buildFfmpegArgs(this.config));
37973
+ const args = buildFfmpegArgs(this.config);
37974
+ const child = spawn(this.ffmpegPath, args);
37662
37975
  this.process = child;
37663
37976
  child.stdin?.on("error", () => {});
37664
37977
  child.stdout?.on("data", (chunk) => {
@@ -37791,7 +38104,14 @@ var SUPPORTED_CODECS = new Set([
37791
38104
  "hevc",
37792
38105
  "mjpeg"
37793
38106
  ]);
38107
+ /**
38108
+ * A static decoder provider for stand-alone broker harnesses. Production
38109
+ * decodes through the `decoder` cap; this exists for the manual streaming
38110
+ * diagnostics. It is handed the pinned ffmpeg (D540) — there is no PATH
38111
+ * default for it to fall back to.
38112
+ */
37794
38113
  var FfmpegDecoderProvider = class {
38114
+ ffmpegPath;
37795
38115
  id = "ffmpeg";
37796
38116
  name = "FFmpeg Decoder";
37797
38117
  /** Software decoder — used as fallback when hardware decoders are unavailable. */
@@ -37799,8 +38119,11 @@ var FfmpegDecoderProvider = class {
37799
38119
  async supportsCodec(input) {
37800
38120
  return SUPPORTED_CODECS.has(input.codec);
37801
38121
  }
38122
+ constructor(ffmpegPath) {
38123
+ this.ffmpegPath = ffmpegPath;
38124
+ }
37802
38125
  async createSession(config) {
37803
- return new FfmpegDecoderSession(config);
38126
+ return new FfmpegDecoderSession(config, this.ffmpegPath);
37804
38127
  }
37805
38128
  };
37806
38129
  //#endregion