@camstack/addon-pipeline 1.2.307 → 1.2.308

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 (31) 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-2rHeRf0Y.js} +1 -1
  4. package/dist/{default-detection-model-BV9bIuOo.mjs → default-detection-model-DATyir0t.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-CyJWk-_f.js → dist-CdRsXAov.js} +79 -1
  8. package/dist/{dist-DdVMFlJ6.mjs → dist-jy-7xoW5.mjs} +74 -2
  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-NCge1qZM.mjs} +1 -53
  12. package/dist/{node-HsWG5fGp.js → node-pWHpwpBa.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-4K7QaQab.mjs} +1 -1
  16. package/dist/{process-memory-TQXG7qrJ.js → process-memory-C4ayPnww.js} +1 -1
  17. package/dist/recorder/index.js +82 -15
  18. package/dist/recorder/index.mjs +82 -15
  19. package/dist/{segment-demux-js-BrKa7tpr.js → segment-demux-js-0bM2Anej.js} +22 -4
  20. package/dist/{segment-demux-js-CEJn4BR0.mjs → segment-demux-js-BaI2VdPX.mjs} +17 -5
  21. package/dist/stream-broker/_stub.js +1 -1
  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-CkxD__Uy.mjs} +1 -1
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DEPTgXS_.mjs +26 -0
  24. package/dist/stream-broker/demux-worker-child.js +1 -1
  25. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  26. package/dist/stream-broker/{hostInit-D--a9rVM.mjs → hostInit-D56Gr-hD.mjs} +1 -1
  27. package/dist/stream-broker/index.js +305 -95
  28. package/dist/stream-broker/index.mjs +305 -95
  29. package/dist/stream-broker/remoteEntry.js +1 -1
  30. package/package.json +1 -1
  31. 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
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-CyJWk-_f.js");
7
- const require_node = require("../node-HsWG5fGp.js");
6
+ const require_dist = require("../dist-CdRsXAov.js");
7
+ const require_node = require("../node-pWHpwpBa.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-BrKa7tpr.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-0bM2Anej.js");
12
12
  let node_os = require("node:os");
13
13
  node_os = require_chunk.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
@@ -103,6 +103,86 @@ function createClientLogIngest(onEntries) {
103
103
  });
104
104
  };
105
105
  }
106
+ var FfmpegReresolver = class {
107
+ deps;
108
+ inFlight = null;
109
+ lastAttemptAt = null;
110
+ constructor(deps) {
111
+ this.deps = deps;
112
+ }
113
+ /**
114
+ * Ask again, unless an attempt is running or one started inside the bound.
115
+ * Never throws; the outcome is logged either way.
116
+ */
117
+ request(reason) {
118
+ if (this.inFlight !== null) return "in-flight";
119
+ const now = (this.deps.now ?? Date.now)();
120
+ const minIntervalMs = this.deps.minIntervalMs ?? 3e4;
121
+ if (this.lastAttemptAt !== null && now - this.lastAttemptAt < minIntervalMs) return "too-soon";
122
+ this.lastAttemptAt = now;
123
+ this.deps.logger.warn("stream-broker: re-resolving the pinned ffmpeg after a refusal (D540)", { meta: { reason } });
124
+ this.inFlight = this.attempt().finally(() => {
125
+ this.inFlight = null;
126
+ });
127
+ return "started";
128
+ }
129
+ /** The attempt in flight, for a caller (or a spec) that wants to wait. */
130
+ settled() {
131
+ return this.inFlight ?? Promise.resolve();
132
+ }
133
+ async attempt() {
134
+ try {
135
+ const binaryPath = await this.deps.resolve();
136
+ this.deps.apply(binaryPath);
137
+ this.deps.logger.info("stream-broker: pinned ffmpeg resolved on retry — children may spawn", { meta: { binaryPath } });
138
+ } catch (err) {
139
+ this.deps.logger.error("stream-broker: pinned ffmpeg still unavailable — every ffmpeg child keeps refusing", { meta: { error: err instanceof Error ? err.message : String(err) } });
140
+ }
141
+ }
142
+ };
143
+ /**
144
+ * The broker addon's whole ffmpeg wiring, in one place a spec can drive: the
145
+ * boot-time resolution, the refusal handler on the manager, and the
146
+ * re-resolve that hands a later success back to the manager's config — which
147
+ * every live broker reads per dial.
148
+ */
149
+ function wireBrokerFfmpeg(deps) {
150
+ const give = (binaryPath) => {
151
+ deps.host.setFfmpegConfig({
152
+ binaryPath,
153
+ hwAccel: deps.hwAccel(),
154
+ threadCount: 0
155
+ });
156
+ deps.onBinaryPath?.(binaryPath);
157
+ };
158
+ const reresolver = new FfmpegReresolver({
159
+ resolve: deps.resolve,
160
+ apply: give,
161
+ logger: deps.logger,
162
+ ...deps.now !== void 0 ? { now: deps.now } : {},
163
+ ...deps.minIntervalMs !== void 0 ? { minIntervalMs: deps.minIntervalMs } : {}
164
+ });
165
+ deps.host.setFfmpegMissingHandler(() => {
166
+ reresolver.request("a broker ffmpeg child was refused");
167
+ });
168
+ return {
169
+ load: async () => {
170
+ try {
171
+ const binaryPath = await deps.resolve();
172
+ give(binaryPath);
173
+ deps.logger.info("Loaded ffmpeg config", { meta: {
174
+ binaryPath,
175
+ hwAccel: deps.hwAccel()
176
+ } });
177
+ } catch (err) {
178
+ give(null);
179
+ 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) } });
180
+ }
181
+ },
182
+ request: (reason) => reresolver.request(reason),
183
+ settled: () => reresolver.settled()
184
+ };
185
+ }
106
186
  //#endregion
107
187
  //#region src/stream-broker/log-channels.ts
108
188
  /**
@@ -5453,6 +5533,16 @@ function classifyDecodeHwAccel(setting) {
5453
5533
  prefer: setting
5454
5534
  };
5455
5535
  }
5536
+ /**
5537
+ * The binary to spawn, or a THROW after an ERROR naming the camera and the
5538
+ * child that was refused. Never a PATH name (D540).
5539
+ */
5540
+ function requireBrokerFfmpeg(binaryPath, ask) {
5541
+ if (binaryPath !== null) return binaryPath;
5542
+ ask.logger.error(`${ask.purpose} refused — no pinned ffmpeg on this node (D540)`, { ...ask.deviceId !== null ? { tags: { deviceId: ask.deviceId } } : {} });
5543
+ ask.onMissing?.();
5544
+ throw new Error(`${ask.purpose}: no pinned ffmpeg resolved on this node`);
5545
+ }
5456
5546
  /** Narrow an audio target to a sidecar-capable codec, or `null` (copy/none). */
5457
5547
  function toAudioSidecarCodec(audio) {
5458
5548
  return audio === "aac" || audio === "opus" || audio === "pcmu" ? audio : null;
@@ -6570,13 +6660,19 @@ var EgressTranscodeManager = class {
6570
6660
  }
6571
6661
  createChild(spec) {
6572
6662
  if (this.opts.createChild) return this.opts.createChild(spec);
6573
- if (spec.fragments) return this.createFragmentChild(spec, spec.fragments);
6574
- if (spec.pushSink) return this.createPushChild(spec, spec.pushSink);
6663
+ const ffmpegBinaryPath = requireBrokerFfmpeg(this.opts.ffmpegConfig().binaryPath, {
6664
+ logger: this.opts.logger,
6665
+ deviceId: spec.deviceId,
6666
+ purpose: "egress transcode",
6667
+ ...this.opts.onFfmpegMissing === void 0 ? {} : { onMissing: this.opts.onFfmpegMissing }
6668
+ });
6669
+ if (spec.fragments) return this.createFragmentChild(spec, spec.fragments, ffmpegBinaryPath);
6670
+ if (spec.pushSink) return this.createPushChild(spec, spec.pushSink, ffmpegBinaryPath);
6575
6671
  const channel = spec.channel;
6576
6672
  if (channel === void 0) throw new Error("egress transcode: a dial child was created without a channel");
6577
6673
  const egress = new TranscodeEgress({
6578
6674
  logger: this.opts.logger.child("egress"),
6579
- ffmpegBinaryPath: this.opts.ffmpegConfig().binaryPath,
6675
+ ffmpegBinaryPath,
6580
6676
  spawnFn: this.opts.spawnFn ?? node_child_process.spawn,
6581
6677
  acquireChannel: () => channel,
6582
6678
  ...spec.onDied ? { onChildExit: spec.onDied } : {},
@@ -6608,10 +6704,10 @@ var EgressTranscodeManager = class {
6608
6704
  * same shape as the other two transports; this wrapper is the adapter, not
6609
6705
  * the logic. See the primitive's `ffmpeg/fmp4-fragment-child.ts`.
6610
6706
  */
6611
- createFragmentChild(spec, fragments) {
6707
+ createFragmentChild(spec, fragments, ffmpegBinaryPath) {
6612
6708
  const child = new require_node.Fmp4FragmentChild({
6613
6709
  logger: this.opts.logger.child("egress-fmp4"),
6614
- ffmpegBinaryPath: this.opts.ffmpegConfig().binaryPath,
6710
+ ffmpegBinaryPath,
6615
6711
  spawnFn: this.opts.spawnFn ?? node_child_process.spawn,
6616
6712
  ...this.opts.fragmentFirstUnitTimeoutMs === void 0 ? {} : { firstUnitTimeoutMs: this.opts.fragmentFirstUnitTimeoutMs },
6617
6713
  ...spec.onDied ? { onChildExit: spec.onDied } : {}
@@ -6661,7 +6757,7 @@ var EgressTranscodeManager = class {
6661
6757
  * masked the same failure on the dial path with its own software retry, which
6662
6758
  * is why only this transport was dark.
6663
6759
  */
6664
- createPushChild(spec, sink) {
6760
+ createPushChild(spec, sink, ffmpegBinaryPath) {
6665
6761
  const logger = this.opts.logger.child("egress-push");
6666
6762
  const deviceId = spec.deviceId;
6667
6763
  const timeoutMs = this.opts.pushFirstPacketTimeoutMs ?? DEFAULT_PUSH_FIRST_PACKET_TIMEOUT_MS;
@@ -6679,7 +6775,7 @@ var EgressTranscodeManager = class {
6679
6775
  const makeReader = (input) => {
6680
6776
  if (this.opts.createPushReader) return this.opts.createPushReader(input);
6681
6777
  return new FfmpegPushReader({
6682
- ffmpegPath: this.opts.ffmpegConfig().binaryPath,
6778
+ ffmpegPath: ffmpegBinaryPath,
6683
6779
  args: [...input.args],
6684
6780
  logger,
6685
6781
  ...this.opts.spawnFn ? { spawnFn: this.opts.spawnFn } : {}
@@ -6706,7 +6802,7 @@ var EgressTranscodeManager = class {
6706
6802
  meta: {
6707
6803
  brokerId: spec.brokerId,
6708
6804
  camStreamId: spec.publishedCamStreamId,
6709
- ffmpegPath: this.opts.ffmpegConfig().binaryPath,
6805
+ ffmpegPath: ffmpegBinaryPath,
6710
6806
  decodeHwAccel: decodeHwAccel ?? "software",
6711
6807
  argv: args.join(" ")
6712
6808
  }
@@ -7125,7 +7221,7 @@ async function gifFromMp4(input) {
7125
7221
  }
7126
7222
  function runGif(input, args) {
7127
7223
  return new Promise((resolve, reject) => {
7128
- const proc = input.spawnFn(input.ffmpegPath ?? "ffmpeg", [...args], { stdio: [
7224
+ const proc = input.spawnFn(input.ffmpegPath, [...args], { stdio: [
7129
7225
  "pipe",
7130
7226
  "pipe",
7131
7227
  "pipe"
@@ -7989,7 +8085,7 @@ async function renderPreBufferClip(input) {
7989
8085
  hevc,
7990
8086
  sourceWidth: input.sourceWidth ?? 0
7991
8087
  });
7992
- const proc = input.spawn(input.ffmpegPath ?? "ffmpeg", clipFfmpegArgs({
8088
+ const proc = input.spawn(input.ffmpegPath, clipFfmpegArgs({
7993
8089
  ...input,
7994
8090
  hevc,
7995
8091
  copyVideo
@@ -12806,11 +12902,12 @@ var StreamBroker = class StreamBroker {
12806
12902
  /** Active "ffmpeg parser" ingress reader when `source.ffmpegParser` is set. */
12807
12903
  ffmpegSourceReader = null;
12808
12904
  /**
12809
- * Resolved `ffmpeg` binary path for the ffmpeg-parser ingress, threaded from
12810
- * the manager's system-config (`ffmpeg.binaryPath`). Defaults to bare
12811
- * `'ffmpeg'` (PATH lookup) for tests / stand-alone callers.
12905
+ * Where the ffmpeg-parser ingress reads the node's pinned ffmpeg — the
12906
+ * manager's LIVE config, asked at each dial rather than copied at creation,
12907
+ * so a broker created while a re-resolve was in flight still sees its result.
12908
+ * `null` — the ingress REFUSES by name; there is no PATH default (D540).
12812
12909
  */
12813
- ffmpegBinaryPath = "ffmpeg";
12910
+ ffmpegBinaryPathSource = () => null;
12814
12911
  rtpDepacketizer = null;
12815
12912
  /** Flag-only depacketizer for the `push-rtp` source-RTP pre-buffer path
12816
12913
  * (sets keyframe/param-set flags without re-emitting AnnexB packets). */
@@ -13393,12 +13490,21 @@ var StreamBroker = class StreamBroker {
13393
13490
  this.restreamUrlResolver = resolver;
13394
13491
  }
13395
13492
  /**
13396
- * Inject the resolved `ffmpeg` binary path used by the "ffmpeg parser"
13397
- * ingress ({@link startFfmpegSourceReader}). Threaded from the manager's
13398
- * system-config (`ffmpeg.binaryPath`). Absent = bare `'ffmpeg'` (PATH lookup).
13493
+ * Inject where the "ffmpeg parser" ingress ({@link startFfmpegSourceReader})
13494
+ * reads the node's pinned ffmpeg: the manager's live config, asked per dial.
13399
13495
  */
13400
- setFfmpegBinaryPath(binaryPath) {
13401
- this.ffmpegBinaryPath = binaryPath;
13496
+ setFfmpegBinaryPathSource(source) {
13497
+ this.ffmpegBinaryPathSource = source;
13498
+ }
13499
+ /** The pinned ffmpeg this broker would spawn right now (`null` = none). */
13500
+ getFfmpegBinaryPath() {
13501
+ return this.ffmpegBinaryPathSource();
13502
+ }
13503
+ /** Told when the ingress was refused for want of the pinned ffmpeg. */
13504
+ ffmpegMissingHandler = null;
13505
+ /** Wire the manager's re-resolve request. */
13506
+ setFfmpegMissingHandler(handler) {
13507
+ this.ffmpegMissingHandler = handler;
13402
13508
  }
13403
13509
  /**
13404
13510
  * Inject the addon's child-cost registry. Injected rather than constructed
@@ -15324,18 +15430,28 @@ var StreamBroker = class StreamBroker {
15324
15430
  transport: source.ffmpegParser?.transport ?? "tcp"
15325
15431
  } });
15326
15432
  this.destroyFfmpegSourceReader();
15433
+ const ffmpegPath = this.ffmpegBinaryPathSource();
15434
+ if (ffmpegPath === null) {
15435
+ this.logger?.error("ffmpeg source reader refused — no pinned ffmpeg on this node (D540)", {
15436
+ ...this.numericDeviceId === void 0 ? {} : { tags: { deviceId: this.numericDeviceId } },
15437
+ meta: { url: require_dist.maskUrlCredentials(source.url) }
15438
+ });
15439
+ this._status = "error";
15440
+ this.ffmpegMissingHandler?.();
15441
+ this.scheduleReconnect();
15442
+ return;
15443
+ }
15327
15444
  const parser = source.ffmpegParser;
15328
15445
  const sourceKind = source.type === "rtmp" ? "rtmp" : source.type === "flv" || source.type === "http" ? "http" : "rtsp";
15329
- const args = buildFfmpegSourceReaderArgs({
15330
- sourceUrl: source.url,
15331
- sourceKind,
15332
- transport: parser?.transport ?? "tcp",
15333
- ...parser?.inputArgs ? { inputArgs: parser.inputArgs } : {},
15334
- ...parser?.outputArgs ? { outputArgs: parser.outputArgs } : {}
15335
- });
15336
15446
  const reader = new FfmpegSourceReader({
15337
- ffmpegPath: this.ffmpegBinaryPath,
15338
- args,
15447
+ ffmpegPath,
15448
+ args: buildFfmpegSourceReaderArgs({
15449
+ sourceUrl: source.url,
15450
+ sourceKind,
15451
+ transport: parser?.transport ?? "tcp",
15452
+ ...parser?.inputArgs ? { inputArgs: parser.inputArgs } : {},
15453
+ ...parser?.outputArgs ? { outputArgs: parser.outputArgs } : {}
15454
+ }),
15339
15455
  ...this.logger ? { logger: this.logger.child("ffmpeg-source") } : {},
15340
15456
  claimCost: (pid) => this.claimIngestCost(pid)
15341
15457
  }, {
@@ -16311,7 +16427,7 @@ function hexToBytes(hex) {
16311
16427
  * pipe is the pattern proven by the WebRTC transcode feed.
16312
16428
  */
16313
16429
  var DEFAULT_FFMPEG_CONFIG = {
16314
- binaryPath: "ffmpeg",
16430
+ binaryPath: null,
16315
16431
  hwAccel: "copy",
16316
16432
  threadCount: 0
16317
16433
  };
@@ -16345,6 +16461,7 @@ var TranscodePipelineManager = class {
16345
16461
  resolveProfileSource;
16346
16462
  releaseGraceMs;
16347
16463
  ffmpegConfig;
16464
+ onFfmpegMissing;
16348
16465
  resolveStreamHwAccel;
16349
16466
  spawnFn;
16350
16467
  acquireChannel;
@@ -16357,6 +16474,7 @@ var TranscodePipelineManager = class {
16357
16474
  this.resolveProfileSource = opts.resolveProfileSource ?? null;
16358
16475
  this.releaseGraceMs = opts.releaseGraceMs ?? 5e3;
16359
16476
  this.ffmpegConfig = opts.ffmpegConfig ?? (() => DEFAULT_FFMPEG_CONFIG);
16477
+ this.onFfmpegMissing = opts.onFfmpegMissing ?? null;
16360
16478
  this.probeFfmpeg = opts.probeFfmpeg ?? require_node.probeFfmpegBinary;
16361
16479
  this.resolveStreamHwAccel = opts.resolveStreamHwAccel ?? null;
16362
16480
  this.spawnFn = opts.spawnFn ?? node_child_process.spawn;
@@ -16429,11 +16547,17 @@ var TranscodePipelineManager = class {
16429
16547
  async startTranscode(opts) {
16430
16548
  if (!this.acquireChannel) throw new Error("getStreamWithCodec: transcode egress not wired (acquireChannel missing)");
16431
16549
  const cfg = this.ffmpegConfig();
16550
+ const ffmpegBinaryPath = requireBrokerFfmpeg(cfg.binaryPath, {
16551
+ logger: this.logger,
16552
+ deviceId: opts.deviceId,
16553
+ purpose: "transcode egress",
16554
+ ...this.onFfmpegMissing === null ? {} : { onMissing: this.onFfmpegMissing }
16555
+ });
16432
16556
  const effectiveHwAccel = this.effectiveHwAccelSetting(opts.deviceId, opts.camStreamId, cfg.hwAccel);
16433
16557
  const choice = chooseVideoEncoder({
16434
16558
  target: opts.targetCodec,
16435
16559
  backend: await this.resolveHardwareBackend(),
16436
- binaryEncoders: await this.resolveBinaryEncoders(cfg.binaryPath),
16560
+ binaryEncoders: await this.resolveBinaryEncoders(ffmpegBinaryPath),
16437
16561
  hwAccelSetting: effectiveHwAccel,
16438
16562
  renderNode: DEFAULT_VAAPI_RENDER_DEVICE
16439
16563
  });
@@ -16463,7 +16587,7 @@ var TranscodePipelineManager = class {
16463
16587
  const sidecarCodec = opts.audioEgress ? toAudioSidecarCodec(opts.audio) : null;
16464
16588
  const egress = new TranscodeEgress({
16465
16589
  logger: this.logger.child("egress"),
16466
- ffmpegBinaryPath: cfg.binaryPath,
16590
+ ffmpegBinaryPath,
16467
16591
  spawnFn: this.spawnFn,
16468
16592
  acquireChannel: this.acquireChannel
16469
16593
  }, {
@@ -16692,11 +16816,12 @@ var TranscodePipelineManager = class {
16692
16816
  try {
16693
16817
  const backend = rankDecodeHwAccels((await this.hwaccel.resolve(prefer)).preferred, [])[0] ?? null;
16694
16818
  if (backend === null) return null;
16695
- const caps = await this.resolveBinaryCapabilities(this.ffmpegConfig().binaryPath);
16819
+ const binaryPath = this.ffmpegConfig().binaryPath;
16820
+ const caps = binaryPath === null ? null : await this.resolveBinaryCapabilities(binaryPath);
16696
16821
  if (caps !== null && !caps.hwaccels.has(backend)) {
16697
16822
  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: {
16698
16823
  backend,
16699
- ffmpeg: this.ffmpegConfig().binaryPath,
16824
+ ffmpeg: binaryPath,
16700
16825
  hwaccels: [...caps.hwaccels].join(",") || "none"
16701
16826
  } });
16702
16827
  return null;
@@ -17377,12 +17502,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17377
17502
  /**
17378
17503
  * Snapshot of the system `ffmpeg` config section (binaryPath / hwAccel /
17379
17504
  * threadCount). Refreshed by the host addon via `setFfmpegConfig`; the
17380
- * transcode manager reads it live per spawn. Defaults are safe (PATH ffmpeg,
17381
- * `'copy'` software decode, auto threads) until the addon loads the section —
17382
- * software decode never hard-fails on a fragile HEVC source.
17505
+ * transcode manager reads it live per spawn. Until the addon resolves the
17506
+ * node's pinned binary there is NONE (`binaryPath: null`) and every child
17507
+ * refuses by name — never a PATH `ffmpeg` (D540). `'copy'` software decode
17508
+ * and auto threads stay the defaults: software decode never hard-fails on a
17509
+ * fragile HEVC source.
17383
17510
  */
17384
17511
  ffmpegConfig = {
17385
- binaryPath: "ffmpeg",
17512
+ binaryPath: null,
17386
17513
  hwAccel: "copy",
17387
17514
  threadCount: 0
17388
17515
  };
@@ -17418,6 +17545,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17418
17545
  },
17419
17546
  resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
17420
17547
  ffmpegConfig: () => this.ffmpegConfig,
17548
+ onFfmpegMissing: () => this.reportFfmpegMissing(),
17421
17549
  resolveStreamHwAccel: (deviceId, camStreamId) => this.getStreamHwAccelOverride(deviceId, camStreamId),
17422
17550
  sentryOffered: async (deviceId) => sentryEligible({
17423
17551
  batteryOperated: await this.isBatteryOperated(deviceId),
@@ -17480,6 +17608,7 @@ var StreamBrokerManager = class StreamBrokerManager {
17480
17608
  });
17481
17609
  },
17482
17610
  ffmpegConfig: () => this.ffmpegConfig,
17611
+ onFfmpegMissing: () => this.reportFfmpegMissing(),
17483
17612
  ...this.costRegistry === null ? {} : { costRegistry: this.costRegistry },
17484
17613
  resolveDecodeHwAccel: (deviceId, override) => this.resolveEgressHwAccel(deviceId, override),
17485
17614
  acquireChannel: (brokerId) => {
@@ -17855,11 +17984,35 @@ var StreamBrokerManager = class StreamBrokerManager {
17855
17984
  if (localNodeId !== void 0) this.localNodeId = localNodeId;
17856
17985
  if (hwaccel !== void 0) this.hwaccel = hwaccel;
17857
17986
  }
17858
- /** Update the system `ffmpeg` config snapshot used by the transcode egress. */
17987
+ /**
17988
+ * Update the node's ffmpeg config snapshot. Every broker READS it per dial
17989
+ * ({@link bindBrokerFfmpeg}), so a re-resolved binary reaches live brokers —
17990
+ * including one created while the re-resolve was in flight — with no copy to
17991
+ * fan out (D540).
17992
+ */
17859
17993
  setFfmpegConfig(cfg) {
17860
17994
  this.ffmpegConfig = cfg;
17861
17995
  }
17862
17996
  /**
17997
+ * Point a broker at the node's LIVE pinned ffmpeg and at the re-resolve
17998
+ * request, so its ffmpeg-parser ingress spawns the current binary and a
17999
+ * refusal asks for it again.
18000
+ */
18001
+ bindBrokerFfmpeg(broker) {
18002
+ broker.setFfmpegBinaryPathSource(() => this.ffmpegConfig.binaryPath);
18003
+ broker.setFfmpegMissingHandler(() => this.reportFfmpegMissing());
18004
+ }
18005
+ /** Who asks for the pinned binary again after a refusal. Set by the addon. */
18006
+ ffmpegMissingHandler = null;
18007
+ /** Wire the host's re-resolve (`FfmpegReresolver.request`). */
18008
+ setFfmpegMissingHandler(handler) {
18009
+ this.ffmpegMissingHandler = handler;
18010
+ }
18011
+ /** A child was refused for want of the pinned ffmpeg: ask the host again. */
18012
+ reportFfmpegMissing() {
18013
+ this.ffmpegMissingHandler?.();
18014
+ }
18015
+ /**
17863
18016
  * The decode `hwaccel` options surfaceable in a (future) UI: the always-valid
17864
18017
  * settings (`copy`, `auto`, `none`) plus the concrete hardware backends THIS
17865
18018
  * node can use, resolved from the LOCAL `ctx.kernel.hwaccel` (os.platform +
@@ -18200,7 +18353,12 @@ var StreamBrokerManager = class StreamBrokerManager {
18200
18353
  let annexB;
18201
18354
  try {
18202
18355
  annexB = await encodePrestreamIdr({
18203
- ffmpegPath: this.ffmpegConfig.binaryPath,
18356
+ ffmpegPath: requireBrokerFfmpeg(this.ffmpegConfig.binaryPath, {
18357
+ logger,
18358
+ deviceId,
18359
+ purpose: "sentry pre-stream encode",
18360
+ onMissing: () => this.reportFfmpegMissing()
18361
+ }),
18204
18362
  plan,
18205
18363
  jpeg: picture.jpeg,
18206
18364
  logger,
@@ -19452,7 +19610,12 @@ var StreamBrokerManager = class StreamBrokerManager {
19452
19610
  height: scaledHeight(cut, clipWidth)
19453
19611
  });
19454
19612
  const gifSpeed = input.gifSpeed ?? 4;
19455
- if (input.kinds.includes("gif")) {
19613
+ const gifFfmpeg = this.ffmpegConfig.binaryPath;
19614
+ if (input.kinds.includes("gif") && gifFfmpeg === null) {
19615
+ this.logger?.error("event media gif refused — no pinned ffmpeg on this node (D540)", { tags: { deviceId: input.deviceId } });
19616
+ this.reportFfmpegMissing();
19617
+ }
19618
+ if (input.kinds.includes("gif") && gifFfmpeg !== null) {
19456
19619
  const gif = await gifFromMp4({
19457
19620
  mp4,
19458
19621
  deviceId: input.deviceId,
@@ -19461,7 +19624,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19461
19624
  speed: require_dist.gifDeriveSpeed(gifSpeed, input.speed),
19462
19625
  ...this.logger !== void 0 ? { logger: this.logger } : {},
19463
19626
  spawnFn: node_child_process.spawn,
19464
- ...this.ffmpegConfig.binaryPath !== void 0 ? { ffmpegPath: this.ffmpegConfig.binaryPath } : {}
19627
+ ffmpegPath: gifFfmpeg
19465
19628
  });
19466
19629
  if (gif !== null) {
19467
19630
  const height = scaledHeight(cut, input.gifMaxWidth);
@@ -19630,7 +19793,12 @@ var StreamBrokerManager = class StreamBrokerManager {
19630
19793
  "pipe",
19631
19794
  "pipe"
19632
19795
  ] }),
19633
- ffmpegPath: this.ffmpegConfig.binaryPath,
19796
+ ffmpegPath: requireBrokerFfmpeg(this.ffmpegConfig.binaryPath, {
19797
+ logger: this.logger,
19798
+ deviceId: cut.deviceId,
19799
+ purpose: "pre-buffer clip render",
19800
+ onMissing: () => this.reportFfmpegMissing()
19801
+ }),
19634
19802
  sdpParameterSets: cut.broker.getSdpParameterSets()
19635
19803
  });
19636
19804
  this.logger?.info("pre-buffer clip rendered", {
@@ -21708,7 +21876,7 @@ var StreamBrokerManager = class StreamBrokerManager {
21708
21876
  const source = this.buildSourceFromCamStream(cam);
21709
21877
  const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api), this.localNodeId);
21710
21878
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
21711
- broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
21879
+ this.bindBrokerFfmpeg(broker);
21712
21880
  if (this.costRegistry !== null) broker.setCostRegistry(this.costRegistry);
21713
21881
  this.applyPrebufferConfig(broker, deviceId, camStreamId);
21714
21882
  this.applyAudioMuteConfig(broker, deviceId);
@@ -21886,7 +22054,12 @@ var StreamBrokerManager = class StreamBrokerManager {
21886
22054
  }
21887
22055
  });
21888
22056
  const feeder = new DerivedStreamFeeder({
21889
- ffmpegPath: this.ffmpegConfig.binaryPath,
22057
+ ffmpegPath: requireBrokerFfmpeg(this.ffmpegConfig.binaryPath, {
22058
+ logger: this.logger,
22059
+ deviceId,
22060
+ purpose: "derived transcode",
22061
+ onMissing: () => this.reportFfmpegMissing()
22062
+ }),
21890
22063
  args,
21891
22064
  logger: this.logger.child(`derived-ffmpeg:${camStreamId}`).withTags({ deviceId })
21892
22065
  }, {
@@ -33834,7 +34007,7 @@ var offThreadJsParse = (bytes) => {
33834
34007
  * Drop-in for the session `demuxVideo` hook: JS parse off the live loop,
33835
34008
  * ffmpeg subprocess fallback unchanged.
33836
34009
  */
33837
- async function demuxSegmentPreferJsOffThread(bytes, opts = {}) {
34010
+ async function demuxSegmentPreferJsOffThread(bytes, opts) {
33838
34011
  return require_segment_demux_js.demuxSegmentPreferJs(bytes, {
33839
34012
  ...opts,
33840
34013
  jsParse: offThreadJsParse
@@ -34019,9 +34192,9 @@ function sliceMuLawToPcmuFrames(mulaw) {
34019
34192
  * can't decode) — ffmpeg exits non-zero, which is tolerated as empty output.
34020
34193
  *
34021
34194
  * @param bytes - The full `.m4s` fragment.
34022
- * @param ffmpegPath - ffmpeg binary path (defaults to PATH `ffmpeg`).
34195
+ * @param ffmpegPath - The node's pinned ffmpeg (D540). Required: no PATH default.
34023
34196
  */
34024
- async function demuxSegmentToPcmu(bytes, ffmpegPath = "ffmpeg") {
34197
+ async function demuxSegmentToPcmu(bytes, ffmpegPath) {
34025
34198
  const mulaw = await require_segment_demux_js.runFfmpegOnce({
34026
34199
  ffmpegPath,
34027
34200
  args: SEGMENT_AUDIO_FFMPEG_ARGS,
@@ -34153,15 +34326,19 @@ var SEGMENT_AUDIO_OPUS_FFMPEG_ARGS = [
34153
34326
  * best-effort and must never block recorded video. Mirrors the video-side
34154
34327
  * `demuxSegmentPreferJs`.
34155
34328
  */
34156
- async function demuxSegmentAudioOpusPreferJs(bytes, opts = {}) {
34329
+ async function demuxSegmentAudioOpusPreferJs(bytes, opts) {
34157
34330
  try {
34158
34331
  const direct = await demuxSegmentAudioOpusJs(bytes);
34159
34332
  if (direct) return direct;
34160
34333
  } catch {}
34334
+ if (opts.ffmpegPath === null) {
34335
+ opts.onFfmpegFallback?.("audio track is not Opus (legacy fragment) and the ffmpeg fallback is refused — no pinned ffmpeg on this node (D540)");
34336
+ return [];
34337
+ }
34161
34338
  opts.onFfmpegFallback?.("audio track is not Opus (legacy fragment)");
34162
34339
  try {
34163
34340
  const opusFmp4 = await require_segment_demux_js.runFfmpegOnce({
34164
- ffmpegPath: opts.ffmpegPath ?? "ffmpeg",
34341
+ ffmpegPath: opts.ffmpegPath,
34165
34342
  args: SEGMENT_AUDIO_OPUS_FFMPEG_ARGS,
34166
34343
  input: bytes,
34167
34344
  tolerateNonZeroExit: true
@@ -35223,12 +35400,25 @@ var BrokerWebrtcServer = class {
35223
35400
  session.onClosed = () => this.cleanupSession(sessionId);
35224
35401
  const locateRecorded = this.locateRecordedSegment;
35225
35402
  const readRecorded = this.readRecordedSegmentBytes;
35226
- const resolveFfmpeg = this.resolveFfmpegPath;
35403
+ const resolveFfmpegPath = this.resolveFfmpegPath;
35404
+ const resolveFfmpeg = () => resolveFfmpegPath?.() ?? null;
35405
+ let noPinnedFfmpegLogged = false;
35406
+ const reportNoPinnedFfmpeg = (what) => {
35407
+ if (noPinnedFfmpegLogged) return;
35408
+ noPinnedFfmpegLogged = true;
35409
+ sessionLogger.error("recorded playback: ffmpeg fallback refused — no pinned ffmpeg on this node (D540)", {
35410
+ tags: { deviceId },
35411
+ meta: {
35412
+ sessionId,
35413
+ what
35414
+ }
35415
+ });
35416
+ };
35227
35417
  const demuxTally = new DemuxPathTally();
35228
35418
  let audioOpusFallbackLogged = false;
35229
35419
  if (locateRecorded && readRecorded) {
35230
35420
  const demuxVideo = (b) => demuxSegmentPreferJsOffThread(b, {
35231
- ffmpegPath: resolveFfmpeg?.(),
35421
+ ffmpegPath: resolveFfmpeg(),
35232
35422
  onDemuxPath: (outcome) => {
35233
35423
  const report = demuxTally.record(outcome, Date.now());
35234
35424
  if (!report) return;
@@ -35243,6 +35433,9 @@ var BrokerWebrtcServer = class {
35243
35433
  }
35244
35434
  });
35245
35435
  }
35436
+ }).catch((err) => {
35437
+ if (err instanceof require_segment_demux_js.FfmpegFallbackRefusedError) reportNoPinnedFfmpeg("video");
35438
+ throw err;
35246
35439
  });
35247
35440
  const scrubCache = new ScrubFragmentCache();
35248
35441
  const initialLiveProfile = this.profileTierResolver?.(brokerId) ?? null;
@@ -35358,9 +35551,11 @@ var BrokerWebrtcServer = class {
35358
35551
  pushAudioPcmu: (pcmu, ts) => session.writeRecordedAudio(Buffer.from(pcmu), ts)
35359
35552
  };
35360
35553
  const demuxAudio = opusSession ? async (b) => {
35554
+ const ffmpegPath = resolveFfmpeg();
35361
35555
  return (await demuxSegmentAudioOpusPreferJs(b, {
35362
- ffmpegPath: resolveFfmpeg?.(),
35556
+ ffmpegPath,
35363
35557
  onFfmpegFallback: (reason) => {
35558
+ if (ffmpegPath === null) reportNoPinnedFfmpeg("audio");
35364
35559
  if (audioOpusFallbackLogged) return;
35365
35560
  audioOpusFallbackLogged = true;
35366
35561
  sessionLogger.warn("recorded audio: JS Opus demux missed — ffmpeg AAC→Opus fallback (once/session)", { meta: {
@@ -35374,7 +35569,14 @@ var BrokerWebrtcServer = class {
35374
35569
  data: au.data,
35375
35570
  ptsMs: au.ptsMs
35376
35571
  }));
35377
- } : (b) => demuxSegmentToPcmu(b, resolveFfmpeg?.());
35572
+ } : async (b) => {
35573
+ const ffmpegPath = resolveFfmpeg();
35574
+ if (ffmpegPath === null) {
35575
+ reportNoPinnedFfmpeg("audio");
35576
+ return [];
35577
+ }
35578
+ return await demuxSegmentToPcmu(b, ffmpegPath);
35579
+ };
35378
35580
  const clipSource = clip && readClip ? createClipFileSource({
35379
35581
  handle: clip,
35380
35582
  read: readClip,
@@ -36824,10 +37026,21 @@ var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
36824
37026
  var NETWORK_QUALITY_STATS_TIMEOUT_MS = 2e3;
36825
37027
  var StreamBrokerAddon = class extends require_dist.BaseAddon {
36826
37028
  brokerManager = null;
36827
- /** ffmpeg binary path from the `ffmpeg` config section, mirrored here so the
36828
- * recorded-playback demuxers (segment-demux/segment-audio) spawn the same
36829
- * configured binary as the transcode egress. Kept in sync by loadFfmpegConfig. */
36830
- ffmpegBinaryPath = "ffmpeg";
37029
+ /** The node's pinned ffmpeg, mirrored here so the recorded-playback demuxers
37030
+ * (segment-demux/segment-audio) spawn the same binary as the transcode
37031
+ * egress. Kept in sync by the ffmpeg wiring. `null` = none could be resolved:
37032
+ * their ffmpeg fallbacks refuse by name — never a PATH `ffmpeg` (D540). */
37033
+ ffmpegBinaryPath = null;
37034
+ /** The node's pinned build, asked once on success (D540). A failure is not
37035
+ * remembered, so the re-resolve below can succeed later. */
37036
+ pinnedFfmpeg = require_dist.memoizePinnedFfmpeg(() => this.ctx.deps.ensureFfmpeg());
37037
+ /**
37038
+ * The way back from a failed boot-time resolution: a refused child asks, and
37039
+ * ONE bounded re-resolve hands the binary to the manager config (which every
37040
+ * live broker reads per dial) and the demuxers. Without it one failed
37041
+ * download disabled every broker ffmpeg child until the runner restarted.
37042
+ */
37043
+ ffmpegWiring = null;
36831
37044
  /** Once-per-addon log guard for the readGopBytes → whole-segment downgrade. */
36832
37045
  gopReadFallbackLogged = false;
36833
37046
  metricsSnapshotTimer = null;
@@ -36888,36 +37101,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
36888
37101
  async resolveFfmpegBinaryPath() {
36889
37102
  const override = this.config.binaryPath;
36890
37103
  if (typeof override === "string" && override.trim().length > 0) return override;
36891
- return await this.ctx.deps.ensureFfmpeg();
36892
- }
36893
- /**
36894
- * Push THIS node's ffmpeg config into the broker — all node-local now, NOT the
36895
- * retired global `ffmpeg` section. `binaryPath` via {@link resolveFfmpegBinaryPath}
36896
- * (per-node override → ensureFfmpeg); `hwAccel` = the per-node `hwaccel`
36897
- * preference (default `'copy'` = software, the safe HEVC-compatible choice; the
36898
- * concrete backend is resolved node-locally by the egress transcode manager,
36899
- * which asks the DECODER ADDON — never the kernel resolver, whose qsv-first
36900
- * order is the known-bad one here). `threadCount` is always `0` (auto — ffmpeg uses the
36901
- * node's own cores; the manual knob was removed).
36902
- */
36903
- async loadFfmpegConfig() {
36904
- if (!this.brokerManager) return;
36905
- try {
36906
- const binaryPath = await this.resolveFfmpegBinaryPath();
36907
- const hwAccel = this.config.hwaccel;
36908
- this.brokerManager.setFfmpegConfig({
36909
- binaryPath,
36910
- hwAccel,
36911
- threadCount: 0
36912
- });
36913
- this.ffmpegBinaryPath = binaryPath;
36914
- this.ctx.logger.info("Loaded ffmpeg config", { meta: {
36915
- binaryPath,
36916
- hwAccel
36917
- } });
36918
- } catch (err) {
36919
- 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: require_dist.errMsg(err) } });
36920
- }
37104
+ return await this.pinnedFfmpeg();
36921
37105
  }
36922
37106
  async onInitialize() {
36923
37107
  this.ctx.logger.info("log channels declared", { meta: { channels: STREAM_BROKER_LOG_CHANNELS.map((c) => c.descriptor.name) } });
@@ -36947,7 +37131,16 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
36947
37131
  };
36948
37132
  this.brokerManager.setDeviceSignalReader(async (deviceId) => (await readSignals(deviceId)).map((s) => s.code));
36949
37133
  this.brokerManager.setDeviceSignalDetailReader(readSignals);
36950
- await this.loadFfmpegConfig();
37134
+ this.ffmpegWiring = wireBrokerFfmpeg({
37135
+ host: this.brokerManager,
37136
+ resolve: () => this.resolveFfmpegBinaryPath(),
37137
+ hwAccel: () => this.config.hwaccel,
37138
+ onBinaryPath: (binaryPath) => {
37139
+ this.ffmpegBinaryPath = binaryPath;
37140
+ },
37141
+ logger: this.ctx.logger
37142
+ });
37143
+ await this.ffmpegWiring.load();
36951
37144
  await this.declareRowCollections();
36952
37145
  await this.purgeFlattenedSettingsKeys();
36953
37146
  const rtspProvider = this.brokerManager.getRtspRestreamProvider();
@@ -37232,7 +37425,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
37232
37425
  };
37233
37426
  }
37234
37427
  },
37235
- resolveFfmpegPath: () => this.ffmpegBinaryPath
37428
+ resolveFfmpegPath: () => {
37429
+ if (this.ffmpegBinaryPath === null) this.ffmpegWiring?.request("a recorded-playback demux needed ffmpeg");
37430
+ return this.ffmpegBinaryPath;
37431
+ }
37236
37432
  });
37237
37433
  this.brokerManager.setWebrtcServer(webrtcServer);
37238
37434
  this.webrtcServer = webrtcServer;
@@ -37642,6 +37838,8 @@ var FfmpegDecoderSession = class {
37642
37838
  outputBuffer = Buffer.alloc(0);
37643
37839
  destroyed = false;
37644
37840
  logger;
37841
+ /** The pinned ffmpeg to spawn (D540). Required: there is no PATH default. */
37842
+ ffmpegPath;
37645
37843
  cachedWidth = 0;
37646
37844
  cachedHeight = 0;
37647
37845
  inputPackets = 0;
@@ -37650,8 +37848,9 @@ var FfmpegDecoderSession = class {
37650
37848
  totalDecodeTimeMs = 0;
37651
37849
  decodeCount = 0;
37652
37850
  startTime = Date.now();
37653
- constructor(config, logger = noopLogger) {
37851
+ constructor(config, ffmpegPath, logger = noopLogger) {
37654
37852
  this.config = { ...config };
37853
+ this.ffmpegPath = ffmpegPath;
37655
37854
  this.logger = logger;
37656
37855
  this.frameDropper = new FrameDropper(config.maxFps);
37657
37856
  this.spawnFfmpeg();
@@ -37662,7 +37861,8 @@ var FfmpegDecoderSession = class {
37662
37861
  this.outputBuffer = Buffer.alloc(0);
37663
37862
  this.cachedWidth = 0;
37664
37863
  this.cachedHeight = 0;
37665
- const child = (0, node_child_process.spawn)("ffmpeg", buildFfmpegArgs(this.config));
37864
+ const args = buildFfmpegArgs(this.config);
37865
+ const child = (0, node_child_process.spawn)(this.ffmpegPath, args);
37666
37866
  this.process = child;
37667
37867
  child.stdin?.on("error", () => {});
37668
37868
  child.stdout?.on("data", (chunk) => {
@@ -37795,7 +37995,14 @@ var SUPPORTED_CODECS = new Set([
37795
37995
  "hevc",
37796
37996
  "mjpeg"
37797
37997
  ]);
37998
+ /**
37999
+ * A static decoder provider for stand-alone broker harnesses. Production
38000
+ * decodes through the `decoder` cap; this exists for the manual streaming
38001
+ * diagnostics. It is handed the pinned ffmpeg (D540) — there is no PATH
38002
+ * default for it to fall back to.
38003
+ */
37798
38004
  var FfmpegDecoderProvider = class {
38005
+ ffmpegPath;
37799
38006
  id = "ffmpeg";
37800
38007
  name = "FFmpeg Decoder";
37801
38008
  /** Software decoder — used as fallback when hardware decoders are unavailable. */
@@ -37803,8 +38010,11 @@ var FfmpegDecoderProvider = class {
37803
38010
  async supportsCodec(input) {
37804
38011
  return SUPPORTED_CODECS.has(input.codec);
37805
38012
  }
38013
+ constructor(ffmpegPath) {
38014
+ this.ffmpegPath = ffmpegPath;
38015
+ }
37806
38016
  async createSession(config) {
37807
- return new FfmpegDecoderSession(config);
38017
+ return new FfmpegDecoderSession(config, this.ffmpegPath);
37808
38018
  }
37809
38019
  };
37810
38020
  //#endregion