@camstack/addon-pipeline 1.2.311 → 1.2.312

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.
@@ -8498,6 +8498,41 @@ function resolveSourceParser(setting, sourceType) {
8498
8498
  }
8499
8499
  }
8500
8500
  //#endregion
8501
+ //#region src/stream-broker/catalog-absence.ts
8502
+ function judgeCatalogAbsence(input) {
8503
+ const { marks, listed, candidates, nowMs, confirmMinMs } = input;
8504
+ const nextMarks = /* @__PURE__ */ new Map();
8505
+ const marked = [];
8506
+ const cleared = [];
8507
+ const retract = [];
8508
+ for (const camStreamId of candidates) {
8509
+ const markedAt = marks.get(camStreamId);
8510
+ if (listed.has(camStreamId)) {
8511
+ if (markedAt !== void 0) cleared.push(camStreamId);
8512
+ continue;
8513
+ }
8514
+ if (markedAt === void 0) {
8515
+ nextMarks.set(camStreamId, nowMs);
8516
+ marked.push(camStreamId);
8517
+ continue;
8518
+ }
8519
+ if (nowMs - markedAt < confirmMinMs) {
8520
+ nextMarks.set(camStreamId, markedAt);
8521
+ continue;
8522
+ }
8523
+ retract.push({
8524
+ camStreamId,
8525
+ absentForMs: nowMs - markedAt
8526
+ });
8527
+ }
8528
+ return {
8529
+ nextMarks,
8530
+ marked,
8531
+ cleared,
8532
+ retract
8533
+ };
8534
+ }
8535
+ //#endregion
8501
8536
  //#region src/stream-broker/rtmp/flv-parser.ts
8502
8537
  var AudioSoundFormat = /* @__PURE__ */ function(AudioSoundFormat) {
8503
8538
  AudioSoundFormat[AudioSoundFormat["PCM_BE"] = 0] = "PCM_BE";
@@ -17098,6 +17133,13 @@ var RtspEnabledSchema = require_dist.record(require_dist.string(), require_dist.
17098
17133
  * one observation.
17099
17134
  */
17100
17135
  var DEPARTURE_CONFIRM_MIN_MS = 3e4;
17136
+ /**
17137
+ * The catalog reconcile's second omission of a stream must be at least this
17138
+ * long after the first (D689) — the same one-poll-interval floor as the
17139
+ * departed-device sweep, for the same reason: an event-driven pull next to a
17140
+ * poll, or the startup pass's quick re-runs, are one observation.
17141
+ */
17142
+ var CATALOG_ABSENCE_CONFIRM_MIN_MS = DEPARTURE_CONFIRM_MIN_MS;
17101
17143
  var PUSH_KINDS = new Set(["push-annexb"]);
17102
17144
  /** Walk a profile map and return the slot that points at `camStreamId`, or null. */
17103
17145
  function findProfileForStream(map, camStreamId) {
@@ -17304,6 +17346,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17304
17346
  * agrees, and a reappearance clears it (D49).
17305
17347
  */
17306
17348
  departureMarks = /* @__PURE__ */ new Map();
17349
+ /**
17350
+ * Per device: provider streams the broker holds that a successful catalog
17351
+ * pull OMITTED, and when (D689). A mark, not a verdict — see
17352
+ * `catalog-absence.ts`. Dropped when the stream is retracted by any path and
17353
+ * when the device is disabled (no pulls run then, so a mark would outlive
17354
+ * the observation it records).
17355
+ */
17356
+ catalogAbsenceMarks = /* @__PURE__ */ new Map();
17307
17357
  eventBus = null;
17308
17358
  /**
17309
17359
  * Per-broker stream health flag set by the watchdog. `undefined`
@@ -19034,6 +19084,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19034
19084
  await this.releaseBroker(deviceId, camStreamId);
19035
19085
  streamMap.delete(camStreamId);
19036
19086
  if (streamMap.size === 0) this.cameraStreams.delete(deviceId);
19087
+ this.dropCatalogAbsenceMark(deviceId, camStreamId);
19037
19088
  this.emitCamStreamsChanged(deviceId);
19038
19089
  return { success: true };
19039
19090
  }
@@ -19095,13 +19146,21 @@ var StreamBrokerManager = class StreamBrokerManager {
19095
19146
  }
19096
19147
  /**
19097
19148
  * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
19098
- * every descriptor, retract any cam-stream no longer in the catalog. A pull
19149
+ * every descriptor, retract a cam-stream the catalog no longer lists. A pull
19099
19150
  * that throws (provider mid-restart, no `stream-catalog` cap, a camera whose
19100
19151
  * session died) omits THIS DEVICE ONLY — it is logged at `warn` with the
19101
- * device's id and re-asked on the `catalog-pull-backoff.ts` ladder. An EMPTY
19102
- * catalog is also skipped rather than tearing down (a provider probe can
19103
- * transiently return none); genuine removal flows through the
19104
- * device-unregistered path.
19152
+ * device's id and re-asked on the `catalog-pull-backoff.ts` ladder.
19153
+ *
19154
+ * The retract DESTROYS work (broker, restreamer, every writer and viewer on
19155
+ * it), so it is never decided by one read (D689, D49): a pull that omits a
19156
+ * stream only marks it, and the retract needs a later successful pull, at
19157
+ * least {@link CATALOG_ABSENCE_CONFIRM_MIN_MS} after the mark, to agree. A
19158
+ * failed pull, or one that answered nothing at all (`null`), decides
19159
+ * nothing. Neither does an answered EMPTY catalog: providers answer `[]`
19160
+ * both for "not mine" and for "don't know yet" (a sleeping Reolink battery
19161
+ * camera, a rebooting Hikvision), so it is not a read that may destroy work
19162
+ * — it neither marks nor clears. Genuine removal of a whole camera is the
19163
+ * `DeviceUnregistered` path and the departed-device sweep (D685).
19105
19164
  *
19106
19165
  * `trigger` decides whether the backoff applies — see
19107
19166
  * {@link CatalogReconcileTrigger}. It defaults to `'event'` (always attempt)
@@ -19123,9 +19182,9 @@ var StreamBrokerManager = class StreamBrokerManager {
19123
19182
  });
19124
19183
  return;
19125
19184
  }
19126
- let descriptors;
19185
+ let answer;
19127
19186
  try {
19128
- descriptors = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId) ?? [];
19187
+ answer = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId);
19129
19188
  } catch (err) {
19130
19189
  const next = nextCatalogPullFailureState(this.catalogPullFailures.get(deviceId), Date.now());
19131
19190
  this.catalogPullFailures.set(deviceId, next);
@@ -19142,21 +19201,85 @@ var StreamBrokerManager = class StreamBrokerManager {
19142
19201
  }
19143
19202
  if (this.catalogPullFailures.delete(deviceId)) this.logger.info("stream-catalog pull recovered — device is back in the catalog", {
19144
19203
  tags: { deviceId },
19145
- meta: { descriptors: descriptors.length }
19204
+ meta: { descriptors: answer?.length ?? null }
19146
19205
  });
19147
- if (descriptors.length === 0) return;
19148
- const keep = new Set(descriptors.map((d) => d.camStreamId));
19206
+ if (answer === null || answer === void 0) {
19207
+ this.logger.debug("stream-catalog pull answered nothing — the registry is left as it is", {
19208
+ tags: { deviceId },
19209
+ meta: { trigger }
19210
+ });
19211
+ return;
19212
+ }
19213
+ const descriptors = answer;
19214
+ if (descriptors.length === 0) {
19215
+ this.logger.debug("stream-catalog pull answered empty — ignored: neither marks nor clears a stream (D689)", {
19216
+ tags: { deviceId },
19217
+ meta: {
19218
+ trigger,
19219
+ pendingMarks: this.catalogAbsenceMarks.get(deviceId)?.size ?? 0
19220
+ }
19221
+ });
19222
+ return;
19223
+ }
19149
19224
  await this.publishCatalog(deviceId, descriptors);
19225
+ await this.retractStreamsTwoPullsOmitted(deviceId, descriptors, trigger);
19226
+ }
19227
+ /**
19228
+ * The retract half of a SUCCESSFUL catalog pull (D689): mark what this pull
19229
+ * omitted, unmark what it lists again, retract what two pulls a reconcile
19230
+ * interval apart both omitted. See `catalog-absence.ts`.
19231
+ */
19232
+ async retractStreamsTwoPullsOmitted(deviceId, descriptors, trigger) {
19150
19233
  const existing = this.cameraStreams.get(deviceId);
19151
- if (existing) for (const [camStreamId, cam] of Array.from(existing.entries())) {
19152
- if (cam.brokerOwned) continue;
19153
- if (!keep.has(camStreamId)) await this.retractCameraStream({
19234
+ const candidates = existing ? Array.from(existing.values()).filter((cam) => !cam.brokerOwned).map((cam) => cam.camStreamId) : [];
19235
+ const verdict = judgeCatalogAbsence({
19236
+ marks: this.catalogAbsenceMarks.get(deviceId) ?? /* @__PURE__ */ new Map(),
19237
+ listed: new Set(descriptors.map((d) => d.camStreamId)),
19238
+ candidates,
19239
+ nowMs: Date.now(),
19240
+ confirmMinMs: CATALOG_ABSENCE_CONFIRM_MIN_MS
19241
+ });
19242
+ if (verdict.nextMarks.size > 0) this.catalogAbsenceMarks.set(deviceId, verdict.nextMarks);
19243
+ else this.catalogAbsenceMarks.delete(deviceId);
19244
+ for (const camStreamId of verdict.marked) this.logger.info("stream absent from the catalog — retracted only if a pull a reconcile interval later agrees", {
19245
+ tags: { deviceId },
19246
+ meta: {
19247
+ camStreamId,
19248
+ trigger,
19249
+ confirmAfterMs: CATALOG_ABSENCE_CONFIRM_MIN_MS
19250
+ }
19251
+ });
19252
+ for (const camStreamId of verdict.cleared) this.logger.info("stream is back in the catalog — pending retract cancelled", {
19253
+ tags: { deviceId },
19254
+ meta: {
19255
+ camStreamId,
19256
+ trigger
19257
+ }
19258
+ });
19259
+ for (const { camStreamId, absentForMs } of verdict.retract) {
19260
+ this.logger.warn("stream absent from two catalog pulls — retracted", {
19261
+ tags: { deviceId },
19262
+ meta: {
19263
+ camStreamId,
19264
+ absentForMs,
19265
+ trigger
19266
+ }
19267
+ });
19268
+ await this.retractCameraStream({
19154
19269
  deviceId,
19155
19270
  camStreamId,
19156
19271
  preserveAssignment: true
19157
19272
  });
19158
19273
  }
19159
19274
  }
19275
+ /** Forget one stream's pending catalog-absence mark — it was retracted by some path. */
19276
+ dropCatalogAbsenceMark(deviceId, camStreamId) {
19277
+ const marks = this.catalogAbsenceMarks.get(deviceId);
19278
+ if (!marks?.has(camStreamId)) return;
19279
+ const next = new Map([...marks].filter(([id]) => id !== camStreamId));
19280
+ if (next.size > 0) this.catalogAbsenceMarks.set(deviceId, next);
19281
+ else this.catalogAbsenceMarks.delete(deviceId);
19282
+ }
19160
19283
  /**
19161
19284
  * Enumerate every camera and reconcile its catalog. The reconcile backstop
19162
19285
  * (poll cadence) and the start-up fetch. Per-device failures are isolated so
@@ -19176,6 +19299,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19176
19299
  for (const d of cameras) if (d.disabled) {
19177
19300
  const firstSeen = !this.disabledDevices.has(d.id);
19178
19301
  this.disabledDevices.add(d.id);
19302
+ this.catalogAbsenceMarks.delete(d.id);
19179
19303
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
19180
19304
  } else this.disabledDevices.delete(d.id);
19181
19305
  const live = cameras.filter((d) => !d.disabled);
@@ -8494,6 +8494,41 @@ function resolveSourceParser(setting, sourceType) {
8494
8494
  }
8495
8495
  }
8496
8496
  //#endregion
8497
+ //#region src/stream-broker/catalog-absence.ts
8498
+ function judgeCatalogAbsence(input) {
8499
+ const { marks, listed, candidates, nowMs, confirmMinMs } = input;
8500
+ const nextMarks = /* @__PURE__ */ new Map();
8501
+ const marked = [];
8502
+ const cleared = [];
8503
+ const retract = [];
8504
+ for (const camStreamId of candidates) {
8505
+ const markedAt = marks.get(camStreamId);
8506
+ if (listed.has(camStreamId)) {
8507
+ if (markedAt !== void 0) cleared.push(camStreamId);
8508
+ continue;
8509
+ }
8510
+ if (markedAt === void 0) {
8511
+ nextMarks.set(camStreamId, nowMs);
8512
+ marked.push(camStreamId);
8513
+ continue;
8514
+ }
8515
+ if (nowMs - markedAt < confirmMinMs) {
8516
+ nextMarks.set(camStreamId, markedAt);
8517
+ continue;
8518
+ }
8519
+ retract.push({
8520
+ camStreamId,
8521
+ absentForMs: nowMs - markedAt
8522
+ });
8523
+ }
8524
+ return {
8525
+ nextMarks,
8526
+ marked,
8527
+ cleared,
8528
+ retract
8529
+ };
8530
+ }
8531
+ //#endregion
8497
8532
  //#region src/stream-broker/rtmp/flv-parser.ts
8498
8533
  var AudioSoundFormat = /* @__PURE__ */ function(AudioSoundFormat) {
8499
8534
  AudioSoundFormat[AudioSoundFormat["PCM_BE"] = 0] = "PCM_BE";
@@ -17094,6 +17129,13 @@ var RtspEnabledSchema = record(string(), boolean());
17094
17129
  * one observation.
17095
17130
  */
17096
17131
  var DEPARTURE_CONFIRM_MIN_MS = 3e4;
17132
+ /**
17133
+ * The catalog reconcile's second omission of a stream must be at least this
17134
+ * long after the first (D689) — the same one-poll-interval floor as the
17135
+ * departed-device sweep, for the same reason: an event-driven pull next to a
17136
+ * poll, or the startup pass's quick re-runs, are one observation.
17137
+ */
17138
+ var CATALOG_ABSENCE_CONFIRM_MIN_MS = DEPARTURE_CONFIRM_MIN_MS;
17097
17139
  var PUSH_KINDS = new Set(["push-annexb"]);
17098
17140
  /** Walk a profile map and return the slot that points at `camStreamId`, or null. */
17099
17141
  function findProfileForStream(map, camStreamId) {
@@ -17300,6 +17342,14 @@ var StreamBrokerManager = class StreamBrokerManager {
17300
17342
  * agrees, and a reappearance clears it (D49).
17301
17343
  */
17302
17344
  departureMarks = /* @__PURE__ */ new Map();
17345
+ /**
17346
+ * Per device: provider streams the broker holds that a successful catalog
17347
+ * pull OMITTED, and when (D689). A mark, not a verdict — see
17348
+ * `catalog-absence.ts`. Dropped when the stream is retracted by any path and
17349
+ * when the device is disabled (no pulls run then, so a mark would outlive
17350
+ * the observation it records).
17351
+ */
17352
+ catalogAbsenceMarks = /* @__PURE__ */ new Map();
17303
17353
  eventBus = null;
17304
17354
  /**
17305
17355
  * Per-broker stream health flag set by the watchdog. `undefined`
@@ -19030,6 +19080,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19030
19080
  await this.releaseBroker(deviceId, camStreamId);
19031
19081
  streamMap.delete(camStreamId);
19032
19082
  if (streamMap.size === 0) this.cameraStreams.delete(deviceId);
19083
+ this.dropCatalogAbsenceMark(deviceId, camStreamId);
19033
19084
  this.emitCamStreamsChanged(deviceId);
19034
19085
  return { success: true };
19035
19086
  }
@@ -19091,13 +19142,21 @@ var StreamBrokerManager = class StreamBrokerManager {
19091
19142
  }
19092
19143
  /**
19093
19144
  * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
19094
- * every descriptor, retract any cam-stream no longer in the catalog. A pull
19145
+ * every descriptor, retract a cam-stream the catalog no longer lists. A pull
19095
19146
  * that throws (provider mid-restart, no `stream-catalog` cap, a camera whose
19096
19147
  * session died) omits THIS DEVICE ONLY — it is logged at `warn` with the
19097
- * device's id and re-asked on the `catalog-pull-backoff.ts` ladder. An EMPTY
19098
- * catalog is also skipped rather than tearing down (a provider probe can
19099
- * transiently return none); genuine removal flows through the
19100
- * device-unregistered path.
19148
+ * device's id and re-asked on the `catalog-pull-backoff.ts` ladder.
19149
+ *
19150
+ * The retract DESTROYS work (broker, restreamer, every writer and viewer on
19151
+ * it), so it is never decided by one read (D689, D49): a pull that omits a
19152
+ * stream only marks it, and the retract needs a later successful pull, at
19153
+ * least {@link CATALOG_ABSENCE_CONFIRM_MIN_MS} after the mark, to agree. A
19154
+ * failed pull, or one that answered nothing at all (`null`), decides
19155
+ * nothing. Neither does an answered EMPTY catalog: providers answer `[]`
19156
+ * both for "not mine" and for "don't know yet" (a sleeping Reolink battery
19157
+ * camera, a rebooting Hikvision), so it is not a read that may destroy work
19158
+ * — it neither marks nor clears. Genuine removal of a whole camera is the
19159
+ * `DeviceUnregistered` path and the departed-device sweep (D685).
19101
19160
  *
19102
19161
  * `trigger` decides whether the backoff applies — see
19103
19162
  * {@link CatalogReconcileTrigger}. It defaults to `'event'` (always attempt)
@@ -19119,9 +19178,9 @@ var StreamBrokerManager = class StreamBrokerManager {
19119
19178
  });
19120
19179
  return;
19121
19180
  }
19122
- let descriptors;
19181
+ let answer;
19123
19182
  try {
19124
- descriptors = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId) ?? [];
19183
+ answer = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId);
19125
19184
  } catch (err) {
19126
19185
  const next = nextCatalogPullFailureState(this.catalogPullFailures.get(deviceId), Date.now());
19127
19186
  this.catalogPullFailures.set(deviceId, next);
@@ -19138,21 +19197,85 @@ var StreamBrokerManager = class StreamBrokerManager {
19138
19197
  }
19139
19198
  if (this.catalogPullFailures.delete(deviceId)) this.logger.info("stream-catalog pull recovered — device is back in the catalog", {
19140
19199
  tags: { deviceId },
19141
- meta: { descriptors: descriptors.length }
19200
+ meta: { descriptors: answer?.length ?? null }
19142
19201
  });
19143
- if (descriptors.length === 0) return;
19144
- const keep = new Set(descriptors.map((d) => d.camStreamId));
19202
+ if (answer === null || answer === void 0) {
19203
+ this.logger.debug("stream-catalog pull answered nothing — the registry is left as it is", {
19204
+ tags: { deviceId },
19205
+ meta: { trigger }
19206
+ });
19207
+ return;
19208
+ }
19209
+ const descriptors = answer;
19210
+ if (descriptors.length === 0) {
19211
+ this.logger.debug("stream-catalog pull answered empty — ignored: neither marks nor clears a stream (D689)", {
19212
+ tags: { deviceId },
19213
+ meta: {
19214
+ trigger,
19215
+ pendingMarks: this.catalogAbsenceMarks.get(deviceId)?.size ?? 0
19216
+ }
19217
+ });
19218
+ return;
19219
+ }
19145
19220
  await this.publishCatalog(deviceId, descriptors);
19221
+ await this.retractStreamsTwoPullsOmitted(deviceId, descriptors, trigger);
19222
+ }
19223
+ /**
19224
+ * The retract half of a SUCCESSFUL catalog pull (D689): mark what this pull
19225
+ * omitted, unmark what it lists again, retract what two pulls a reconcile
19226
+ * interval apart both omitted. See `catalog-absence.ts`.
19227
+ */
19228
+ async retractStreamsTwoPullsOmitted(deviceId, descriptors, trigger) {
19146
19229
  const existing = this.cameraStreams.get(deviceId);
19147
- if (existing) for (const [camStreamId, cam] of Array.from(existing.entries())) {
19148
- if (cam.brokerOwned) continue;
19149
- if (!keep.has(camStreamId)) await this.retractCameraStream({
19230
+ const candidates = existing ? Array.from(existing.values()).filter((cam) => !cam.brokerOwned).map((cam) => cam.camStreamId) : [];
19231
+ const verdict = judgeCatalogAbsence({
19232
+ marks: this.catalogAbsenceMarks.get(deviceId) ?? /* @__PURE__ */ new Map(),
19233
+ listed: new Set(descriptors.map((d) => d.camStreamId)),
19234
+ candidates,
19235
+ nowMs: Date.now(),
19236
+ confirmMinMs: CATALOG_ABSENCE_CONFIRM_MIN_MS
19237
+ });
19238
+ if (verdict.nextMarks.size > 0) this.catalogAbsenceMarks.set(deviceId, verdict.nextMarks);
19239
+ else this.catalogAbsenceMarks.delete(deviceId);
19240
+ for (const camStreamId of verdict.marked) this.logger.info("stream absent from the catalog — retracted only if a pull a reconcile interval later agrees", {
19241
+ tags: { deviceId },
19242
+ meta: {
19243
+ camStreamId,
19244
+ trigger,
19245
+ confirmAfterMs: CATALOG_ABSENCE_CONFIRM_MIN_MS
19246
+ }
19247
+ });
19248
+ for (const camStreamId of verdict.cleared) this.logger.info("stream is back in the catalog — pending retract cancelled", {
19249
+ tags: { deviceId },
19250
+ meta: {
19251
+ camStreamId,
19252
+ trigger
19253
+ }
19254
+ });
19255
+ for (const { camStreamId, absentForMs } of verdict.retract) {
19256
+ this.logger.warn("stream absent from two catalog pulls — retracted", {
19257
+ tags: { deviceId },
19258
+ meta: {
19259
+ camStreamId,
19260
+ absentForMs,
19261
+ trigger
19262
+ }
19263
+ });
19264
+ await this.retractCameraStream({
19150
19265
  deviceId,
19151
19266
  camStreamId,
19152
19267
  preserveAssignment: true
19153
19268
  });
19154
19269
  }
19155
19270
  }
19271
+ /** Forget one stream's pending catalog-absence mark — it was retracted by some path. */
19272
+ dropCatalogAbsenceMark(deviceId, camStreamId) {
19273
+ const marks = this.catalogAbsenceMarks.get(deviceId);
19274
+ if (!marks?.has(camStreamId)) return;
19275
+ const next = new Map([...marks].filter(([id]) => id !== camStreamId));
19276
+ if (next.size > 0) this.catalogAbsenceMarks.set(deviceId, next);
19277
+ else this.catalogAbsenceMarks.delete(deviceId);
19278
+ }
19156
19279
  /**
19157
19280
  * Enumerate every camera and reconcile its catalog. The reconcile backstop
19158
19281
  * (poll cadence) and the start-up fetch. Per-device failures are isolated so
@@ -19172,6 +19295,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19172
19295
  for (const d of cameras) if (d.disabled) {
19173
19296
  const firstSeen = !this.disabledDevices.has(d.id);
19174
19297
  this.disabledDevices.add(d.id);
19298
+ this.catalogAbsenceMarks.delete(d.id);
19175
19299
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
19176
19300
  } else this.disabledDevices.delete(d.id);
19177
19301
  const live = cameras.filter((d) => !d.disabled);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.311",
3
+ "version": "1.2.312",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",