@camstack/addon-pipeline 1.2.222 → 1.2.224

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.
@@ -7770,6 +7770,7 @@ function buildRecordingProvider(deps) {
7770
7770
  },
7771
7771
  setDeviceConfig: async ({ deviceId, config }) => {
7772
7772
  const saved = await saveDeviceConfig(deps.configStore, deviceId, config);
7773
+ deps.onConfigSaved?.(deviceId, saved);
7773
7774
  if (deps.onConfigChanged) try {
7774
7775
  await deps.onConfigChanged(deviceId);
7775
7776
  } catch (err) {
@@ -10956,29 +10957,75 @@ Object.freeze({
10956
10957
  "turn-provider": ["getTurnServers"],
10957
10958
  "user-passkeys": ["listPasskeys"]
10958
10959
  });
10959
- //#endregion
10960
- //#region src/recorder/addon/last-known-config.ts
10961
10960
  /**
10962
- * Wrap `load` so a failed read serves the last value that succeeded.
10961
+ * Build the recorder's config mirror.
10963
10962
  *
10964
- * Returns a drop-in replacement for {@link LoadDeviceConfigFn}: every existing
10965
- * call site gains the fallback without changing, which is the point — the
10966
- * trigger post-buffer was the path that got CAUGHT writing a wrong number, but
10967
- * it is one of five reads and the other four fail the same way.
10963
+ * `load` is the fallible store read (`loadDeviceConfig(configStore, id)`), and
10964
+ * every existing call site keeps its signature: the controller is handed
10965
+ * {@link RecordingConfigMirror.read} and cannot tell the difference, which is
10966
+ * the point — the trigger post-buffer is the path that got CAUGHT writing a
10967
+ * wrong number, but it is one of six reads and the other five are the volume.
10968
10968
  */
10969
- function withLastKnownConfig(deps) {
10969
+ function createRecordingConfigMirror(deps) {
10970
10970
  const now = deps.now ?? (() => Date.now());
10971
- const cache = /* @__PURE__ */ new Map();
10972
- return async (deviceId) => {
10971
+ const freshMs = deps.freshMs ?? 3e4;
10972
+ const reportIntervalMs = deps.reportIntervalMs ?? 6e4;
10973
+ const mirror = /* @__PURE__ */ new Map();
10974
+ const window = {
10975
+ served: 0,
10976
+ storeReads: 0,
10977
+ fallbacks: 0,
10978
+ startedAt: now()
10979
+ };
10980
+ /**
10981
+ * One line per window, fleet-level (no `deviceId`: this aggregates every
10982
+ * camera, and the per-camera lines are the fallback WARNs above).
10983
+ *
10984
+ * It exists because a branch that ACCEPTS work and produces no store traffic
10985
+ * is indistinguishable from a branch nobody calls: without this line, a
10986
+ * mirror that silently stopped mirroring and a mirror doing its job both look
10987
+ * like silence, and the only visible difference is 1 800 calls a minute on a
10988
+ * runner nobody is watching.
10989
+ */
10990
+ const reportIfDue = (atMs) => {
10991
+ const windowMs = atMs - window.startedAt;
10992
+ if (windowMs < reportIntervalMs) return;
10993
+ const calls = window.served + window.storeReads;
10994
+ window.startedAt = atMs;
10995
+ const served = window.served;
10996
+ const storeReads = window.storeReads;
10997
+ const fallbacks = window.fallbacks;
10998
+ window.served = 0;
10999
+ window.storeReads = 0;
11000
+ window.fallbacks = 0;
11001
+ if (calls === 0) return;
11002
+ deps.logger.info("recorder: config mirror window", { meta: {
11003
+ served,
11004
+ storeReads,
11005
+ fallbacks,
11006
+ devices: mirror.size,
11007
+ windowMs,
11008
+ freshMs
11009
+ } });
11010
+ };
11011
+ const read = async (deviceId) => {
11012
+ const atMs = now();
11013
+ reportIfDue(atMs);
11014
+ const held = mirror.get(deviceId);
11015
+ if (held !== void 0 && atMs - held.atMs < freshMs) {
11016
+ window.served += 1;
11017
+ return held.config;
11018
+ }
11019
+ window.storeReads += 1;
10973
11020
  try {
10974
11021
  const config = await deps.load(deviceId);
10975
- cache.set(deviceId, {
11022
+ mirror.set(deviceId, {
10976
11023
  config,
10977
11024
  atMs: now()
10978
11025
  });
10979
11026
  return config;
10980
11027
  } catch (err) {
10981
- const cached = cache.get(deviceId);
11028
+ const cached = mirror.get(deviceId);
10982
11029
  if (cached === void 0) {
10983
11030
  deps.logger.warn("recorder: config read failed and NOTHING is cached for this camera — no last-known-good to serve", {
10984
11031
  tags: { deviceId },
@@ -10986,17 +11033,29 @@ function withLastKnownConfig(deps) {
10986
11033
  });
10987
11034
  throw err;
10988
11035
  }
10989
- const ageMs = now() - cached.atMs;
11036
+ window.fallbacks += 1;
10990
11037
  deps.logger.warn("recorder: config read failed — serving the LAST-KNOWN-GOOD config (not the shipped default)", {
10991
11038
  tags: { deviceId },
10992
11039
  meta: {
10993
- ageMs,
11040
+ ageMs: now() - cached.atMs,
10994
11041
  error: errMsg(err)
10995
11042
  }
10996
11043
  });
10997
11044
  return cached.config;
10998
11045
  }
10999
11046
  };
11047
+ return {
11048
+ read,
11049
+ put(deviceId, config) {
11050
+ mirror.set(deviceId, {
11051
+ config,
11052
+ atMs: now()
11053
+ });
11054
+ },
11055
+ invalidate(deviceId) {
11056
+ mirror.delete(deviceId);
11057
+ }
11058
+ };
11000
11059
  }
11001
11060
  //#endregion
11002
11061
  //#region src/recorder/addon/ledger-walk-reconcile.ts
@@ -17452,21 +17511,22 @@ var RecorderV2Addon = class extends BaseAddon {
17452
17511
  /** Continuous + events band write-path controller (ffmpeg writers + watchers). */
17453
17512
  controller = null;
17454
17513
  /**
17455
- * The controller's config read, with last-known-good fallback.
17514
+ * The controller's config read — this addon's mirror of the rows it owns
17515
+ * (`config-mirror.ts`, D466), with the last-known-good fallback.
17456
17516
  *
17457
- * Built ONCE and memoized, because the cache lives INSIDE the wrapper:
17458
- * rebuilding it per call would throw away the very value the fallback exists
17459
- * to serve. Lazy rather than a field initializer — a field initializer runs
17460
- * before the constructor assigns `this.ctx`, so `this.ctx.logger` would be
17461
- * `undefined` at that point.
17517
+ * Built ONCE and memoized, because the map lives INSIDE it: rebuilding it per
17518
+ * call would throw away both the freshness the trigger path is served from
17519
+ * and the last value a failed read falls back to. Lazy rather than a field
17520
+ * initializer — a field initializer runs before the constructor assigns
17521
+ * `this.ctx`, so `this.ctx.logger` would be `undefined` at that point.
17462
17522
  */
17463
- lastKnownGoodRead = null;
17464
- get loadConfigLastKnownGood() {
17465
- this.lastKnownGoodRead ??= withLastKnownConfig({
17523
+ mirroredConfigRead = null;
17524
+ get configMirror() {
17525
+ this.mirroredConfigRead ??= createRecordingConfigMirror({
17466
17526
  load: (deviceId) => loadDeviceConfig(this.configStore(), deviceId),
17467
17527
  logger: this.ctx.logger
17468
17528
  });
17469
- return this.lastKnownGoodRead;
17529
+ return this.mirroredConfigRead;
17470
17530
  }
17471
17531
  /** Unsubscribe handle for the EventCapture motion subscription. */
17472
17532
  eventUnsub = null;
@@ -17714,7 +17774,7 @@ var RecorderV2Addon = class extends BaseAddon {
17714
17774
  nodeId: this.nodeId,
17715
17775
  ownerNodeId: ingestOwner.ownerNodeId,
17716
17776
  segmentStore: this.segmentStore,
17717
- loadConfig: this.loadConfigLastKnownGood,
17777
+ loadConfig: this.configMirror.read,
17718
17778
  enabledDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
17719
17779
  /**
17720
17780
  * The DURABLE half of the trigger path (D8/D11). `pipelineAnalytics` rows
@@ -17853,6 +17913,9 @@ var RecorderV2Addon = class extends BaseAddon {
17853
17913
  calendar: this.calendar,
17854
17914
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
17855
17915
  hydrateWindow: (deviceId, fromMs, toMs, verifyClosedHours) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0, { verifyClosedHours: verifyClosedHours === true }),
17916
+ onConfigSaved: (deviceId, config) => {
17917
+ this.configMirror.put(deviceId, config);
17918
+ },
17856
17919
  onConfigChanged: async (deviceId) => {
17857
17920
  await this.refreshSensorTriggerBindings();
17858
17921
  await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
@@ -9409,42 +9409,14 @@ function extractQuoted(header, key) {
9409
9409
  const unquotedRegex = new RegExp(`${key}=([^,\\s]+)`, "i");
9410
9410
  return header.match(unquotedRegex)?.[1];
9411
9411
  }
9412
- //#endregion
9413
- //#region src/stream-broker/stream-broker/audio-chunk-plane.ts
9414
- /**
9415
- * `AudioChunkPlane` — the broker's serialisable decoded audio-chunk surface
9416
- * (Phase 5 / D9).
9417
- *
9418
- * This is the poll-based replacement for `StreamBroker.onDecodedAudioChunk`'s
9419
- * live-object callback path. A live callback cannot cross a process boundary;
9420
- * once the `pipeline` group is dissolved (Task 8) the audio consumer
9421
- * (`pipeline-orchestrator`) runs in a different process from the broker, so
9422
- * audio delivery must go over tRPC like the video frame plane.
9423
- *
9424
- * ## Why no shared memory
9425
- *
9426
- * Unlike video frames (~6 MB at 1080p RGB), a decoded audio chunk is tiny — a
9427
- * ~500ms `AudioCodecSession` window is a few KB of Float32 PCM. Tiny payloads
9428
- * serialise over tRPC cheaply, so audio ships its bytes INLINE on the RPC
9429
- * wire; there is no shm ring.
9430
- *
9431
- * ## FIFO / no-drop queue policy
9432
- *
9433
- * Audio wants FIFO arrival order with no latest-wins drop: a missing chunk is
9434
- * an audible gap and breaks the audio analyzer's fixed-length analysis
9435
- * window. Each subscription therefore owns a generously-sized FIFO queue
9436
- * (`AUDIO_QUEUE_CAPACITY`). Chunks arrive roughly every
9437
- * `AudioCodecSession`-window (~500ms), so the queue buffers tens of seconds
9438
- * of audio — a healthy poll loop never fills it. The queue only drops its
9439
- * OLDEST chunk if a truly stalled consumer lets it overflow (a bounded safety
9440
- * valve, not the steady-state behaviour).
9441
- */
9442
9412
  /**
9443
- * Per-subscription FIFO capacity. A chunk is a few KB and arrives ~every
9444
- * 500ms, so 64 buffers ~30s of audio between two `pullAudioChunks` polls
9445
- * without unbounded growth.
9413
+ * Re-state a standing overflow at most this often (D465). The line fires when a
9414
+ * subscription STARTS overwriting, and again every 30 s while it still is — a
9415
+ * per-chunk warn is ~25 writes/s per camera of synchronous winston on the
9416
+ * broker's event loop, which is the cost the `pullPcm` warn coalescing exists
9417
+ * to avoid.
9446
9418
  */
9447
- var AUDIO_QUEUE_CAPACITY = 64;
9419
+ var OVERFLOW_RELOG_MS = 3e4;
9448
9420
  /**
9449
9421
  * How long a subscription may go without a `pull` before the plane releases it
9450
9422
  * (D461).
@@ -9515,10 +9487,13 @@ var AudioChunkPlane = class {
9515
9487
  id: subscriptionId,
9516
9488
  tag: input?.tag ?? "unknown",
9517
9489
  subscribedAt: Date.now(),
9518
- queue: new require_dist.RingBuffer(AUDIO_QUEUE_CAPACITY),
9490
+ queue: new require_dist.RingBuffer(64),
9519
9491
  accept: new Set(input?.accept ?? []),
9520
9492
  servedKey: null,
9521
9493
  chunksDelivered: 0,
9494
+ chunksDropped: 0,
9495
+ overflowing: false,
9496
+ lastOverflowLogAt: 0,
9522
9497
  lastPulledAt: Date.now()
9523
9498
  };
9524
9499
  this.subscriptions.set(subscriptionId, subscription);
@@ -9570,6 +9545,7 @@ var AudioChunkPlane = class {
9570
9545
  leaseMs: AUDIO_SUBSCRIPTION_IDLE_MS,
9571
9546
  queued: subscription.queue.size,
9572
9547
  chunksDelivered: subscription.chunksDelivered,
9548
+ chunksDropped: subscription.chunksDropped,
9573
9549
  hint: "the consumer process died without detaching — its subscription was holding this broker in demand"
9574
9550
  } });
9575
9551
  }
@@ -9605,13 +9581,47 @@ var AudioChunkPlane = class {
9605
9581
  for (const subscription of this.subscriptions.values()) {
9606
9582
  if (sourceFormat === "f32le" || subscription.accept.has(sourceFormat)) {
9607
9583
  this.noteServed(subscription, sourceFormat, sourceFormat);
9608
- subscription.queue.push(chunk);
9584
+ this.enqueue(subscription, chunk);
9609
9585
  continue;
9610
9586
  }
9611
9587
  expanded ??= expandChunk(chunk, sourceFormat);
9612
9588
  this.noteServed(subscription, sourceFormat, "f32le");
9613
- subscription.queue.push(expanded);
9589
+ this.enqueue(subscription, expanded);
9590
+ }
9591
+ }
9592
+ /**
9593
+ * Push one chunk into a subscription's FIFO and NAME the loss when the FIFO
9594
+ * had no room (D465).
9595
+ *
9596
+ * `RingBuffer.push` overwrites its oldest entry. Every docblock on this path
9597
+ * called that "a bounded safety valve, not the steady-state behaviour" and
9598
+ * nothing ever said when the valve opened, so a consumer polling too slowly
9599
+ * for its producer lost audio permanently and in silence — the exact shape
9600
+ * CLAUDE.md forbids. The realistic cause is a cadence chosen for the
9601
+ * BOUNDARY rather than for the producer's chunk rate: 500 ms of a ~42 ms AAC
9602
+ * chunk is ~12 chunks against a batch that used to drain 8.
9603
+ */
9604
+ enqueue(subscription, chunk) {
9605
+ const wasFull = subscription.queue.size >= 64;
9606
+ subscription.queue.push(chunk);
9607
+ if (!wasFull) {
9608
+ subscription.overflowing = false;
9609
+ return;
9614
9610
  }
9611
+ subscription.chunksDropped += 1;
9612
+ const now = Date.now();
9613
+ const isNewEpisode = !subscription.overflowing;
9614
+ subscription.overflowing = true;
9615
+ if (!isNewEpisode && now - subscription.lastOverflowLogAt < OVERFLOW_RELOG_MS) return;
9616
+ subscription.lastOverflowLogAt = now;
9617
+ this.logger?.warn("audio-chunk subscription OVERFLOW — oldest audio overwritten", { meta: {
9618
+ subscriptionId: subscription.id,
9619
+ tag: subscription.tag,
9620
+ capacity: 64,
9621
+ chunksDropped: subscription.chunksDropped,
9622
+ chunksDelivered: subscription.chunksDelivered,
9623
+ hint: "the consumer is pulling slower than this camera produces — its poll cadence or drain batch is wrong for the producer, not the boundary"
9624
+ } });
9615
9625
  }
9616
9626
  /**
9617
9627
  * Run the idle-lease sweep on a cadence rather than on every chunk. A quarter
@@ -9669,10 +9679,6 @@ var AudioChunkPlane = class {
9669
9679
  this.subscriptions.clear();
9670
9680
  }
9671
9681
  };
9672
- //#endregion
9673
- //#region src/stream-broker/stream-broker/audio-codec-session.ts
9674
- var POLL_INTERVAL_MS$1 = 40;
9675
- var MAX_PCM_PER_POLL = 16;
9676
9682
  var RTP_HEADER_LENGTH = 12;
9677
9683
  var WARN_THROTTLE_MS$1 = 5e3;
9678
9684
  var SESSION_NOT_FOUND_RE$1 = /decode session\b.*\bnot found\b/;
@@ -9684,19 +9690,23 @@ var AudioCodecSession = class {
9684
9690
  cfg;
9685
9691
  emit;
9686
9692
  logger;
9693
+ realtimeDemand;
9687
9694
  sessionId = null;
9688
9695
  sessionNodeId = null;
9689
9696
  creating = null;
9690
9697
  pollTimer = null;
9698
+ /** The cadence the loop last armed, so a change can be NAMED once (D465). */
9699
+ pollIntervalMs = null;
9691
9700
  closed = false;
9692
9701
  packetsIn = 0;
9693
9702
  packetsDropped = 0;
9694
9703
  warnState = /* @__PURE__ */ new Map();
9695
- constructor(api, cfg, emit, logger) {
9704
+ constructor(api, cfg, emit, logger, realtimeDemand) {
9696
9705
  this.api = api;
9697
9706
  this.cfg = cfg;
9698
9707
  this.emit = emit;
9699
9708
  this.logger = logger;
9709
+ this.realtimeDemand = realtimeDemand;
9700
9710
  }
9701
9711
  processPacket(rtpData) {
9702
9712
  if (this.closed) return;
@@ -9764,7 +9774,7 @@ var AudioCodecSession = class {
9764
9774
  if (this.creating) await this.creating;
9765
9775
  if (this.closed) return;
9766
9776
  if (this.pollTimer) {
9767
- clearInterval(this.pollTimer);
9777
+ clearTimeout(this.pollTimer);
9768
9778
  this.pollTimer = null;
9769
9779
  }
9770
9780
  if (!this.sessionId) return;
@@ -9786,7 +9796,7 @@ var AudioCodecSession = class {
9786
9796
  if (this.closed) return;
9787
9797
  this.closed = true;
9788
9798
  if (this.pollTimer) {
9789
- clearInterval(this.pollTimer);
9799
+ clearTimeout(this.pollTimer);
9790
9800
  this.pollTimer = null;
9791
9801
  }
9792
9802
  if (this.sessionId) {
@@ -9852,11 +9862,43 @@ var AudioCodecSession = class {
9852
9862
  })();
9853
9863
  return this.creating;
9854
9864
  }
9865
+ /**
9866
+ * The cadence this tick should use. Re-read every tick rather than latched at
9867
+ * arm time: a WebRTC session attaching mid-stream must be served at
9868
+ * {@link REALTIME_POLL_INTERVAL_MS}, and it costs at most one idle interval
9869
+ * (400 ms) to notice — against an ICE/DTLS handshake measured in seconds.
9870
+ */
9871
+ currentPollIntervalMs() {
9872
+ if (!this.realtimeDemand) return 40;
9873
+ return this.realtimeDemand() ? 40 : 400;
9874
+ }
9855
9875
  startPollLoop() {
9856
9876
  if (this.pollTimer || this.closed) return;
9857
- this.pollTimer = setInterval(() => {
9858
- this.pollOnce();
9859
- }, POLL_INTERVAL_MS$1);
9877
+ this.scheduleNextPoll();
9878
+ }
9879
+ /**
9880
+ * Self-scheduling tick. `setInterval` cannot change period, and the period is
9881
+ * now a property of who is listening (D465), so the loop re-arms itself.
9882
+ */
9883
+ scheduleNextPoll() {
9884
+ if (this.closed || !this.sessionId) return;
9885
+ const intervalMs = this.currentPollIntervalMs();
9886
+ if (intervalMs !== this.pollIntervalMs) {
9887
+ const previous = this.pollIntervalMs;
9888
+ this.pollIntervalMs = intervalMs;
9889
+ this.logger?.info("audio-codec: poll cadence", { meta: {
9890
+ intervalMs,
9891
+ previousMs: previous,
9892
+ reason: intervalMs === 40 ? "realtime-consumer" : "plane-only",
9893
+ maxPcmPerPoll: 32
9894
+ } });
9895
+ }
9896
+ this.pollTimer = setTimeout(() => {
9897
+ this.pollTimer = null;
9898
+ this.pollOnce().finally(() => {
9899
+ this.scheduleNextPoll();
9900
+ });
9901
+ }, intervalMs);
9860
9902
  if (typeof this.pollTimer.unref === "function") this.pollTimer.unref();
9861
9903
  }
9862
9904
  async pollOnce() {
@@ -9867,7 +9909,7 @@ var AudioCodecSession = class {
9867
9909
  const pcms = await this.api.pullPcm({
9868
9910
  sessionId: sid,
9869
9911
  ...nodeId ? { nodeId } : {},
9870
- maxCount: MAX_PCM_PER_POLL
9912
+ maxCount: 32
9871
9913
  });
9872
9914
  for (const pcm of pcms) this.appendPcm(pcm);
9873
9915
  } catch (err) {
@@ -9911,7 +9953,7 @@ var AudioCodecSession = class {
9911
9953
  this.sessionId = null;
9912
9954
  this.sessionNodeId = null;
9913
9955
  if (this.pollTimer) {
9914
- clearInterval(this.pollTimer);
9956
+ clearTimeout(this.pollTimer);
9915
9957
  this.pollTimer = null;
9916
9958
  }
9917
9959
  this.warnThrottled("audio-codec: session reaped upstream — will re-create on next packet", `staleSessionId=${staleSid}`);
@@ -12619,7 +12661,7 @@ var StreamBroker = class StreamBroker {
12619
12661
  this.logger?.debug("audio-codec: prior session close failed", { meta: { error: require_dist.errMsg(err) } });
12620
12662
  });
12621
12663
  }
12622
- this.audioCodecSession = new AudioCodecSession(this.audioCodecApi, cfg, this.buildAudioCodecEmitCallback(), this.logger?.child("audio-codec"));
12664
+ this.audioCodecSession = new AudioCodecSession(this.audioCodecApi, cfg, this.buildAudioCodecEmitCallback(), this.logger?.child("audio-codec"), () => this.encodedCallbacks.size > 0);
12623
12665
  this.logger?.info("push-audio: decode session armed", { meta: {
12624
12666
  codec: cfg.codec,
12625
12667
  sourceSampleRate: cfg.sourceSampleRate,
@@ -13825,7 +13867,7 @@ var StreamBroker = class StreamBroker {
13825
13867
  this.logger?.debug("audio-codec: prior RTSP session close failed", { meta: { error: require_dist.errMsg(err) } });
13826
13868
  });
13827
13869
  }
13828
- this.audioCodecSession = new AudioCodecSession(this.audioCodecApi, capCfg, this.buildAudioCodecEmitCallback(), this.logger?.child("audio-codec"));
13870
+ this.audioCodecSession = new AudioCodecSession(this.audioCodecApi, capCfg, this.buildAudioCodecEmitCallback(), this.logger?.child("audio-codec"), () => this.encodedCallbacks.size > 0);
13829
13871
  this.logger?.info("audio-codec: decode session armed", { meta: {
13830
13872
  codec: capCfg.codec,
13831
13873
  sourceSampleRate: capCfg.sourceSampleRate,