@camstack/addon-pipeline 1.1.22 → 1.1.24

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.
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.1.17",
21
+ version: "1.1.18",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.1.17",
39
+ version: "1.1.18",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -8904,6 +8904,24 @@ var DEFAULT_HINT_FPS = 5;
8904
8904
  * broker's 2s `inputFps` window so the two stats are comparable. */
8905
8905
  var DECODE_FPS_WINDOW_MS = 2e3;
8906
8906
  /**
8907
+ * Keep-warm grace before a decoder session with no subscribers is torn down.
8908
+ *
8909
+ * Subscribers flap on ~30s cadences (occupancy recheck bursts, detection
8910
+ * motion-gating watch↔active, onboard gray gating). Destroying the
8911
+ * ffmpeg/VAAPI decode session the instant the last subscriber leaves spawns +
8912
+ * destroys a full session every cycle — wasted setup/teardown. Chosen >30s so
8913
+ * consecutive bursts and watch↔active flips reuse the warm session, yet a
8914
+ * genuinely idle session still frees its ffmpeg within ~a minute.
8915
+ */
8916
+ var SESSION_LINGER_MS = 45e3;
8917
+ /**
8918
+ * Default retry cooldown after a decoder session lands on a NON-LOCAL node
8919
+ * (see `FrameHandlePlaneOptions.sessionRetryCooldownMs`). Long enough to
8920
+ * turn a per-packet storm into a slow probe; short enough that the plane
8921
+ * recovers within seconds once the local decoder respawns.
8922
+ */
8923
+ var SESSION_RETRY_COOLDOWN_MS = 1e4;
8924
+ /**
8907
8925
  * Owns the broker's shared-memory frame-handle subscriptions and the
8908
8926
  * per-format `frameSink: 'shm'` decoder sessions that feed them.
8909
8927
  */
@@ -8912,8 +8930,12 @@ var FrameHandlePlane = class {
8912
8930
  logger;
8913
8931
  resolveStreamInfo;
8914
8932
  localNodeId;
8933
+ sessionRetryCooldownMs;
8915
8934
  subscriptions = /* @__PURE__ */ new Map();
8916
8935
  sessions = /* @__PURE__ */ new Map();
8936
+ /** Per-format retry gate set when a session lands on a non-local node:
8937
+ * `ensureSession` skips creation until the epoch-ms deadline passes. */
8938
+ sessionRetryCooldownUntil = /* @__PURE__ */ new Map();
8917
8939
  /** In-flight `ensureSession` creation per format. Coalesces concurrent
8918
8940
  * same-format callers onto ONE decoder session — without it two callers
8919
8941
  * both pass the `sessions.get` check, both `createSession` (two ffmpeg),
@@ -8933,6 +8955,7 @@ var FrameHandlePlane = class {
8933
8955
  this.logger = options.logger;
8934
8956
  this.resolveStreamInfo = options.resolveStreamInfo;
8935
8957
  this.localNodeId = options.localNodeId;
8958
+ this.sessionRetryCooldownMs = options.sessionRetryCooldownMs ?? SESSION_RETRY_COOLDOWN_MS;
8936
8959
  }
8937
8960
  /** Number of active handle subscriptions — surfaced for diagnostics. */
8938
8961
  get subscriberCount() {
@@ -8966,8 +8989,13 @@ var FrameHandlePlane = class {
8966
8989
  nodeId: session.nodeId
8967
8990
  } });
8968
8991
  await this.destroySession(session);
8969
- await this.startSessionDecoder(session);
8970
- rotated += 1;
8992
+ let restarted = false;
8993
+ try {
8994
+ restarted = await this.startSessionDecoder(session);
8995
+ } finally {
8996
+ if (!restarted) this.sessions.delete(session.format);
8997
+ }
8998
+ if (restarted) rotated += 1;
8971
8999
  }
8972
9000
  return rotated;
8973
9001
  }
@@ -9022,17 +9050,18 @@ var FrameHandlePlane = class {
9022
9050
  * underlying decoder session is destroyed (and its shm segment unlinked
9023
9051
  * by the decoder). Returns `true` when a known subscription was released.
9024
9052
  */
9025
- async unsubscribe(subscriptionId) {
9053
+ async unsubscribe(subscriptionId, immediate = false) {
9026
9054
  const subscription = this.subscriptions.get(subscriptionId);
9027
9055
  if (!subscription) return false;
9028
9056
  this.subscriptions.delete(subscriptionId);
9029
9057
  const session = this.sessions.get(subscription.format);
9030
9058
  if (session) {
9031
9059
  session.subscriberIds.delete(subscriptionId);
9032
- if (session.subscriberIds.size === 0) {
9060
+ if (session.subscriberIds.size === 0) if (immediate) {
9061
+ this.clearLinger(session);
9033
9062
  this.sessions.delete(subscription.format);
9034
9063
  await this.destroySession(session);
9035
- }
9064
+ } else this.scheduleLinger(session);
9036
9065
  }
9037
9066
  this.logger?.info("frame-handle subscription removed", { meta: {
9038
9067
  subscriptionId,
@@ -9041,6 +9070,32 @@ var FrameHandlePlane = class {
9041
9070
  return true;
9042
9071
  }
9043
9072
  /**
9073
+ * Arm the keep-warm teardown timer for a session whose last subscriber just
9074
+ * left. Clears any existing timer first (idempotent). On fire it re-checks
9075
+ * `subscriberIds.size === 0` — a subscriber may have returned in the interim,
9076
+ * in which case the timer is a no-op — and only then removes the session and
9077
+ * destroys the decoder. The timer is `unref`'d so a lingering warm session
9078
+ * never keeps the process alive.
9079
+ */
9080
+ scheduleLinger(session) {
9081
+ this.clearLinger(session);
9082
+ const timer = setTimeout(() => {
9083
+ session.lingerTimer = null;
9084
+ if (session.subscriberIds.size > 0) return;
9085
+ this.sessions.delete(session.format);
9086
+ this.destroySession(session).catch(() => {});
9087
+ }, SESSION_LINGER_MS);
9088
+ timer.unref?.();
9089
+ session.lingerTimer = timer;
9090
+ }
9091
+ /** Cancel a session's pending keep-warm teardown timer, if any. */
9092
+ clearLinger(session) {
9093
+ if (session.lingerTimer) {
9094
+ clearTimeout(session.lingerTimer);
9095
+ session.lingerTimer = null;
9096
+ }
9097
+ }
9098
+ /**
9044
9099
  * Forward a video `EncodedPacket` to every active shm decoder session.
9045
9100
  * Push-mode decoders consume this; a pull-mode decoder ignores it (it
9046
9101
  * reads its own pipe via `openStream`). Audio packets are not forwarded.
@@ -9162,7 +9217,7 @@ var FrameHandlePlane = class {
9162
9217
  */
9163
9218
  async killByTag(tag) {
9164
9219
  const victims = [...this.subscriptions.values()].filter((s) => s.tag === tag).map((s) => s.id);
9165
- for (const id of victims) await this.unsubscribe(id);
9220
+ for (const id of victims) await this.unsubscribe(id, true);
9166
9221
  return victims.length;
9167
9222
  }
9168
9223
  /** Tear down every subscription + decoder session. Idempotent. */
@@ -9190,6 +9245,7 @@ var FrameHandlePlane = class {
9190
9245
  async ensureSession(format, subscriptionId) {
9191
9246
  const existing = this.sessions.get(format);
9192
9247
  if (existing) {
9248
+ this.clearLinger(existing);
9193
9249
  existing.subscriberIds.add(subscriptionId);
9194
9250
  return;
9195
9251
  }
@@ -9199,6 +9255,7 @@ var FrameHandlePlane = class {
9199
9255
  this.sessions.get(format)?.subscriberIds.add(subscriptionId);
9200
9256
  return;
9201
9257
  }
9258
+ if (Date.now() < (this.sessionRetryCooldownUntil.get(format) ?? 0)) return;
9202
9259
  const creation = (async () => {
9203
9260
  const subscriberIds = new Set([subscriptionId]);
9204
9261
  for (const subscription of this.subscriptions.values()) if (subscription.format === format) subscriberIds.add(subscription.id);
@@ -9206,9 +9263,17 @@ var FrameHandlePlane = class {
9206
9263
  format,
9207
9264
  proxy: null,
9208
9265
  nodeId: null,
9209
- subscriberIds
9266
+ subscriberIds,
9267
+ lingerTimer: null
9210
9268
  };
9211
- if (await this.startSessionDecoder(session)) this.sessions.set(format, session);
9269
+ let started = false;
9270
+ try {
9271
+ started = await this.startSessionDecoder(session);
9272
+ } catch (err) {
9273
+ this.sessionRetryCooldownUntil.set(format, Date.now() + this.sessionRetryCooldownMs);
9274
+ throw err;
9275
+ }
9276
+ if (started) this.sessions.set(format, session);
9212
9277
  })();
9213
9278
  this.sessionCreating.set(format, creation);
9214
9279
  try {
@@ -9248,11 +9313,13 @@ var FrameHandlePlane = class {
9248
9313
  tag: `${info.tag}:shm:${format}`
9249
9314
  });
9250
9315
  if (!isDecoderNodeColocated(this.localNodeId, nodeId)) {
9316
+ this.sessionRetryCooldownUntil.set(format, Date.now() + this.sessionRetryCooldownMs);
9251
9317
  this.logger?.warn("frame-handle plane: decoder session landed on a non-local node — destroying (shm ring is node-local)", { meta: {
9252
9318
  format,
9253
9319
  requestedNode: this.localNodeId,
9254
9320
  gotNode: nodeId,
9255
- sessionId
9321
+ sessionId,
9322
+ retryInMs: this.sessionRetryCooldownMs
9256
9323
  } });
9257
9324
  await this.decoderApi.destroySession({ sessionId }).catch((err) => {
9258
9325
  this.logger?.warn("frame-handle plane: failed to destroy mis-placed decoder session", { meta: {
@@ -9263,6 +9330,7 @@ var FrameHandlePlane = class {
9263
9330
  });
9264
9331
  return false;
9265
9332
  }
9333
+ this.sessionRetryCooldownUntil.delete(format);
9266
9334
  const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
9267
9335
  session.proxy = proxy;
9268
9336
  session.nodeId = nodeId;
@@ -9299,6 +9367,7 @@ var FrameHandlePlane = class {
9299
9367
  }
9300
9368
  /** Destroy a decoder session, swallowing teardown errors. */
9301
9369
  async destroySession(session) {
9370
+ this.clearLinger(session);
9302
9371
  const proxy = session.proxy;
9303
9372
  session.proxy = null;
9304
9373
  session.nodeId = null;
@@ -10579,12 +10648,12 @@ var StreamBroker = class StreamBroker {
10579
10648
  async subscribeFrameHandles(input) {
10580
10649
  const plane = this.ensureFrameHandlePlane();
10581
10650
  if (!plane) throw new Error("stream-broker: no decoder available for frame-handle subscription");
10582
- this.checkDemand();
10583
10651
  const { subscriptionId, maxFps } = await plane.subscribe({
10584
10652
  format: input.format,
10585
10653
  maxFps: input.maxFps,
10586
10654
  tag: input.tag
10587
10655
  });
10656
+ this.checkDemand();
10588
10657
  return {
10589
10658
  subscriptionId,
10590
10659
  maxFps
@@ -8900,6 +8900,24 @@ var DEFAULT_HINT_FPS = 5;
8900
8900
  * broker's 2s `inputFps` window so the two stats are comparable. */
8901
8901
  var DECODE_FPS_WINDOW_MS = 2e3;
8902
8902
  /**
8903
+ * Keep-warm grace before a decoder session with no subscribers is torn down.
8904
+ *
8905
+ * Subscribers flap on ~30s cadences (occupancy recheck bursts, detection
8906
+ * motion-gating watch↔active, onboard gray gating). Destroying the
8907
+ * ffmpeg/VAAPI decode session the instant the last subscriber leaves spawns +
8908
+ * destroys a full session every cycle — wasted setup/teardown. Chosen >30s so
8909
+ * consecutive bursts and watch↔active flips reuse the warm session, yet a
8910
+ * genuinely idle session still frees its ffmpeg within ~a minute.
8911
+ */
8912
+ var SESSION_LINGER_MS = 45e3;
8913
+ /**
8914
+ * Default retry cooldown after a decoder session lands on a NON-LOCAL node
8915
+ * (see `FrameHandlePlaneOptions.sessionRetryCooldownMs`). Long enough to
8916
+ * turn a per-packet storm into a slow probe; short enough that the plane
8917
+ * recovers within seconds once the local decoder respawns.
8918
+ */
8919
+ var SESSION_RETRY_COOLDOWN_MS = 1e4;
8920
+ /**
8903
8921
  * Owns the broker's shared-memory frame-handle subscriptions and the
8904
8922
  * per-format `frameSink: 'shm'` decoder sessions that feed them.
8905
8923
  */
@@ -8908,8 +8926,12 @@ var FrameHandlePlane = class {
8908
8926
  logger;
8909
8927
  resolveStreamInfo;
8910
8928
  localNodeId;
8929
+ sessionRetryCooldownMs;
8911
8930
  subscriptions = /* @__PURE__ */ new Map();
8912
8931
  sessions = /* @__PURE__ */ new Map();
8932
+ /** Per-format retry gate set when a session lands on a non-local node:
8933
+ * `ensureSession` skips creation until the epoch-ms deadline passes. */
8934
+ sessionRetryCooldownUntil = /* @__PURE__ */ new Map();
8913
8935
  /** In-flight `ensureSession` creation per format. Coalesces concurrent
8914
8936
  * same-format callers onto ONE decoder session — without it two callers
8915
8937
  * both pass the `sessions.get` check, both `createSession` (two ffmpeg),
@@ -8929,6 +8951,7 @@ var FrameHandlePlane = class {
8929
8951
  this.logger = options.logger;
8930
8952
  this.resolveStreamInfo = options.resolveStreamInfo;
8931
8953
  this.localNodeId = options.localNodeId;
8954
+ this.sessionRetryCooldownMs = options.sessionRetryCooldownMs ?? SESSION_RETRY_COOLDOWN_MS;
8932
8955
  }
8933
8956
  /** Number of active handle subscriptions — surfaced for diagnostics. */
8934
8957
  get subscriberCount() {
@@ -8962,8 +8985,13 @@ var FrameHandlePlane = class {
8962
8985
  nodeId: session.nodeId
8963
8986
  } });
8964
8987
  await this.destroySession(session);
8965
- await this.startSessionDecoder(session);
8966
- rotated += 1;
8988
+ let restarted = false;
8989
+ try {
8990
+ restarted = await this.startSessionDecoder(session);
8991
+ } finally {
8992
+ if (!restarted) this.sessions.delete(session.format);
8993
+ }
8994
+ if (restarted) rotated += 1;
8967
8995
  }
8968
8996
  return rotated;
8969
8997
  }
@@ -9018,17 +9046,18 @@ var FrameHandlePlane = class {
9018
9046
  * underlying decoder session is destroyed (and its shm segment unlinked
9019
9047
  * by the decoder). Returns `true` when a known subscription was released.
9020
9048
  */
9021
- async unsubscribe(subscriptionId) {
9049
+ async unsubscribe(subscriptionId, immediate = false) {
9022
9050
  const subscription = this.subscriptions.get(subscriptionId);
9023
9051
  if (!subscription) return false;
9024
9052
  this.subscriptions.delete(subscriptionId);
9025
9053
  const session = this.sessions.get(subscription.format);
9026
9054
  if (session) {
9027
9055
  session.subscriberIds.delete(subscriptionId);
9028
- if (session.subscriberIds.size === 0) {
9056
+ if (session.subscriberIds.size === 0) if (immediate) {
9057
+ this.clearLinger(session);
9029
9058
  this.sessions.delete(subscription.format);
9030
9059
  await this.destroySession(session);
9031
- }
9060
+ } else this.scheduleLinger(session);
9032
9061
  }
9033
9062
  this.logger?.info("frame-handle subscription removed", { meta: {
9034
9063
  subscriptionId,
@@ -9037,6 +9066,32 @@ var FrameHandlePlane = class {
9037
9066
  return true;
9038
9067
  }
9039
9068
  /**
9069
+ * Arm the keep-warm teardown timer for a session whose last subscriber just
9070
+ * left. Clears any existing timer first (idempotent). On fire it re-checks
9071
+ * `subscriberIds.size === 0` — a subscriber may have returned in the interim,
9072
+ * in which case the timer is a no-op — and only then removes the session and
9073
+ * destroys the decoder. The timer is `unref`'d so a lingering warm session
9074
+ * never keeps the process alive.
9075
+ */
9076
+ scheduleLinger(session) {
9077
+ this.clearLinger(session);
9078
+ const timer = setTimeout(() => {
9079
+ session.lingerTimer = null;
9080
+ if (session.subscriberIds.size > 0) return;
9081
+ this.sessions.delete(session.format);
9082
+ this.destroySession(session).catch(() => {});
9083
+ }, SESSION_LINGER_MS);
9084
+ timer.unref?.();
9085
+ session.lingerTimer = timer;
9086
+ }
9087
+ /** Cancel a session's pending keep-warm teardown timer, if any. */
9088
+ clearLinger(session) {
9089
+ if (session.lingerTimer) {
9090
+ clearTimeout(session.lingerTimer);
9091
+ session.lingerTimer = null;
9092
+ }
9093
+ }
9094
+ /**
9040
9095
  * Forward a video `EncodedPacket` to every active shm decoder session.
9041
9096
  * Push-mode decoders consume this; a pull-mode decoder ignores it (it
9042
9097
  * reads its own pipe via `openStream`). Audio packets are not forwarded.
@@ -9158,7 +9213,7 @@ var FrameHandlePlane = class {
9158
9213
  */
9159
9214
  async killByTag(tag) {
9160
9215
  const victims = [...this.subscriptions.values()].filter((s) => s.tag === tag).map((s) => s.id);
9161
- for (const id of victims) await this.unsubscribe(id);
9216
+ for (const id of victims) await this.unsubscribe(id, true);
9162
9217
  return victims.length;
9163
9218
  }
9164
9219
  /** Tear down every subscription + decoder session. Idempotent. */
@@ -9186,6 +9241,7 @@ var FrameHandlePlane = class {
9186
9241
  async ensureSession(format, subscriptionId) {
9187
9242
  const existing = this.sessions.get(format);
9188
9243
  if (existing) {
9244
+ this.clearLinger(existing);
9189
9245
  existing.subscriberIds.add(subscriptionId);
9190
9246
  return;
9191
9247
  }
@@ -9195,6 +9251,7 @@ var FrameHandlePlane = class {
9195
9251
  this.sessions.get(format)?.subscriberIds.add(subscriptionId);
9196
9252
  return;
9197
9253
  }
9254
+ if (Date.now() < (this.sessionRetryCooldownUntil.get(format) ?? 0)) return;
9198
9255
  const creation = (async () => {
9199
9256
  const subscriberIds = new Set([subscriptionId]);
9200
9257
  for (const subscription of this.subscriptions.values()) if (subscription.format === format) subscriberIds.add(subscription.id);
@@ -9202,9 +9259,17 @@ var FrameHandlePlane = class {
9202
9259
  format,
9203
9260
  proxy: null,
9204
9261
  nodeId: null,
9205
- subscriberIds
9262
+ subscriberIds,
9263
+ lingerTimer: null
9206
9264
  };
9207
- if (await this.startSessionDecoder(session)) this.sessions.set(format, session);
9265
+ let started = false;
9266
+ try {
9267
+ started = await this.startSessionDecoder(session);
9268
+ } catch (err) {
9269
+ this.sessionRetryCooldownUntil.set(format, Date.now() + this.sessionRetryCooldownMs);
9270
+ throw err;
9271
+ }
9272
+ if (started) this.sessions.set(format, session);
9208
9273
  })();
9209
9274
  this.sessionCreating.set(format, creation);
9210
9275
  try {
@@ -9244,11 +9309,13 @@ var FrameHandlePlane = class {
9244
9309
  tag: `${info.tag}:shm:${format}`
9245
9310
  });
9246
9311
  if (!isDecoderNodeColocated(this.localNodeId, nodeId)) {
9312
+ this.sessionRetryCooldownUntil.set(format, Date.now() + this.sessionRetryCooldownMs);
9247
9313
  this.logger?.warn("frame-handle plane: decoder session landed on a non-local node — destroying (shm ring is node-local)", { meta: {
9248
9314
  format,
9249
9315
  requestedNode: this.localNodeId,
9250
9316
  gotNode: nodeId,
9251
- sessionId
9317
+ sessionId,
9318
+ retryInMs: this.sessionRetryCooldownMs
9252
9319
  } });
9253
9320
  await this.decoderApi.destroySession({ sessionId }).catch((err) => {
9254
9321
  this.logger?.warn("frame-handle plane: failed to destroy mis-placed decoder session", { meta: {
@@ -9259,6 +9326,7 @@ var FrameHandlePlane = class {
9259
9326
  });
9260
9327
  return false;
9261
9328
  }
9329
+ this.sessionRetryCooldownUntil.delete(format);
9262
9330
  const proxy = new DecoderSessionProxy(this.decoderApi, sessionId);
9263
9331
  session.proxy = proxy;
9264
9332
  session.nodeId = nodeId;
@@ -9295,6 +9363,7 @@ var FrameHandlePlane = class {
9295
9363
  }
9296
9364
  /** Destroy a decoder session, swallowing teardown errors. */
9297
9365
  async destroySession(session) {
9366
+ this.clearLinger(session);
9298
9367
  const proxy = session.proxy;
9299
9368
  session.proxy = null;
9300
9369
  session.nodeId = null;
@@ -10575,12 +10644,12 @@ var StreamBroker = class StreamBroker {
10575
10644
  async subscribeFrameHandles(input) {
10576
10645
  const plane = this.ensureFrameHandlePlane();
10577
10646
  if (!plane) throw new Error("stream-broker: no decoder available for frame-handle subscription");
10578
- this.checkDemand();
10579
10647
  const { subscriptionId, maxFps } = await plane.subscribe({
10580
10648
  format: input.format,
10581
10649
  maxFps: input.maxFps,
10582
10650
  tag: input.tag
10583
10651
  });
10652
+ this.checkDemand();
10584
10653
  return {
10585
10654
  subscriptionId,
10586
10655
  maxFps
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLVitFM4.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BVFAEkIB.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.1.22",
3
+ "version": "1.1.24",
4
4
  "description": "CamStack Pipeline bundle — runner, detection, motion, decoders, audio + stream broker. Multi-entry npm package shipping 7 addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",