@camstack/addon-pipeline 1.1.39 → 1.1.40

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 (38) hide show
  1. package/dist/audio-analyzer/index.js +168 -61
  2. package/dist/audio-analyzer/index.mjs +168 -61
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-DbGdZ8Nr.js → dist-BJTPkJFw.js} +247 -3
  6. package/dist/{dist-DvvYzO58.mjs → dist-v6cKLmU3.mjs} +247 -3
  7. package/dist/{frame-handle-plane-B7jMIZc2.js → frame-handle-plane-BP8YV4sF.js} +54 -5
  8. package/dist/{frame-handle-plane-BFzoIfkd.mjs → frame-handle-plane-DKAXTtfn.mjs} +54 -5
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +893 -12
  12. package/dist/pipeline-runner/index.mjs +892 -13
  13. package/dist/recorder/index.js +2 -2
  14. package/dist/recorder/index.mjs +2 -2
  15. package/dist/session-decode/decode-worker-child.js +511 -0
  16. package/dist/session-decode/decode-worker-child.mjs +510 -0
  17. package/dist/stream-broker/_stub.js +44 -44
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DezFy4Fu.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-6I7-nufj.mjs} +3 -3
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqJvWBKS.mjs +26 -0
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-dGka8yZY.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-BOf0hg9q.mjs → hostInit-CUkFGqiI.mjs} +3 -3
  22. package/dist/stream-broker/index.js +268 -38
  23. package/dist/stream-broker/index.mjs +268 -38
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/worker-protocol-BCfO8gUF.js +67 -0
  26. package/dist/worker-protocol-pk7qdYXt.mjs +56 -0
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Br2fqwe7.js → MaskShapeCanvas-DI4BY7W2-Cr_fCzY5.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DYXRf30g.js → MotionZonesSettings-NcxxQN8r-COcvoHDH.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DLpqLgPo.js → PrivacyMaskSettings-APgPLF7p-Bv-bbyJl.js} +1 -1
  30. package/embed-dist/assets/index-Ctbq9AXh.css +2 -0
  31. package/embed-dist/assets/index-jMj5PyJk.js +81 -0
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +6 -3
  34. package/python/inference_pool.py +3 -1
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DhY3MZ2C.mjs +0 -26
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BsrrdOBU.mjs +0 -26
  37. package/embed-dist/assets/index-C5UpuPr8.css +0 -2
  38. package/embed-dist/assets/index-CR367rYR.js +0 -81
@@ -2,10 +2,13 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DbGdZ8Nr.js");
6
- const require_frame_handle_plane = require("../frame-handle-plane-B7jMIZc2.js");
5
+ const require_dist = require("../dist-BJTPkJFw.js");
6
+ const require_frame_handle_plane = require("../frame-handle-plane-BP8YV4sF.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
+ const require_worker_protocol = require("../worker-protocol-BCfO8gUF.js");
8
9
  let _camstack_shm_ring = require("@camstack/shm-ring");
10
+ let node_child_process = require("node:child_process");
11
+ let node_url = require("node:url");
9
12
  //#region src/pipeline-runner/bench-actions.ts
10
13
  /**
11
14
  * Synthetic-bench custom actions for pipeline-runner.
@@ -233,6 +236,19 @@ var PULL_MAX_COUNT = 4;
233
236
  var MIN_POLL_INTERVAL_MS = 20;
234
237
  /** Poll period when the broker reports a non-positive cadence hint. */
235
238
  var FALLBACK_POLL_INTERVAL_MS = 200;
239
+ /**
240
+ * Ceiling on the poll period — we never poll SLOWER than this even when the
241
+ * subscriber's `maxFps` implies a longer interval (e.g. detection at 10 fps →
242
+ * 100 ms). A decoded frame otherwise sits in the ring up to a full poll period
243
+ * before it is picked, and that wait lands directly in `frameAge` (the analyzed
244
+ * frame's staleness). Polling faster grabs the freshest frame sooner; because
245
+ * the loop reads ONLY the newest handle per tick and the decoder emits at its
246
+ * own rate, the extra ticks that find no new frame are near-free (an empty
247
+ * `pullHandles` + no pixel read), so this lowers latency without a proportional
248
+ * read-cost increase. `maxFps` still bounds the DELIVERED rate downstream via
249
+ * the latest-wins queue — this only decouples poll cadence from it.
250
+ */
251
+ var MAX_POLL_INTERVAL_MS = 50;
236
252
  /** First subscribe-retry delay, doubled on every subsequent failure. */
237
253
  var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
238
254
  /**
@@ -359,7 +375,7 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
359
375
  const { brokerId, onFrame, logger, ownNodeId, getRemoteFrame, tag } = options;
360
376
  const readers = new _camstack_shm_ring.FrameRingReaderCache(logger);
361
377
  const throttle = new ForeignHandleThrottle();
362
- const pollIntervalMs = resolvedMaxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / resolvedMaxFps)) : FALLBACK_POLL_INTERVAL_MS;
378
+ const pollIntervalMs = Math.min(MAX_POLL_INTERVAL_MS, resolvedMaxFps > 0 ? Math.max(MIN_POLL_INTERVAL_MS, Math.round(1e3 / resolvedMaxFps)) : FALLBACK_POLL_INTERVAL_MS);
363
379
  let timer;
364
380
  const tick = async () => {
365
381
  if (lifecycle.stopped) return;
@@ -368,8 +384,9 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
368
384
  subscriptionId,
369
385
  maxCount: PULL_MAX_COUNT
370
386
  });
371
- for (const handle of handles) {
387
+ for (let i = handles.length - 1; i >= 0; i -= 1) {
372
388
  if (lifecycle.stopped) break;
389
+ const handle = handles[i];
373
390
  const frame = await resolvePolledFrame(handle, {
374
391
  ownNodeId,
375
392
  readers,
@@ -378,7 +395,10 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
378
395
  logger,
379
396
  tag
380
397
  });
381
- if (frame) onFrame(frame, handle);
398
+ if (frame) {
399
+ onFrame(frame, handle);
400
+ break;
401
+ }
382
402
  }
383
403
  } catch (err) {
384
404
  logger.warn("frame-handle poller: pullFrameHandles failed", { meta: {
@@ -1074,12 +1094,13 @@ var PipelineRunner = class {
1074
1094
  motionCooldownTimer: null,
1075
1095
  lastArmedSource: null,
1076
1096
  lastArmedRegions: void 0,
1077
- occupancyTimer: null
1097
+ occupancyTimer: null,
1098
+ motionInFlight: false
1078
1099
  };
1079
1100
  this.cameras.set(deviceId, state);
1080
1101
  this.timingSampler.setDroppedFramesSource(deviceId, () => detectionQueue.droppedFrames);
1081
1102
  if (registration.detectionMode === "on-motion") this.defaultRoundRobinKeys.push(deviceId);
1082
- if (registration.detectionMode === "on-motion" && (registration.occupancyRecheckSec ?? 0) > 0) state.occupancyTimer = setInterval(() => {
1103
+ if (registration.detectionMode === "on-motion" && registration.occupancyRecheckEnabled === true && (registration.occupancyRecheckSec ?? 0) > 0) state.occupancyTimer = setInterval(() => {
1083
1104
  if (state.phase === "watching") this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
1084
1105
  }, (registration.occupancyRecheckSec ?? 0) * 1e3);
1085
1106
  if (initialPhase === "active") {
@@ -1304,9 +1325,15 @@ var PipelineRunner = class {
1304
1325
  this.processWithSemaphore(deviceId, entry, frameInput, state, "detection");
1305
1326
  }
1306
1327
  drainMotionQueues() {
1307
- for (const [deviceId, state] of this.cameras) while (state.motionQueue.size > 0) {
1328
+ for (const [deviceId, state] of this.cameras) {
1329
+ if (state.motionInFlight) continue;
1330
+ if (state.motionQueue.size === 0) continue;
1308
1331
  const entry = state.motionQueue.dequeue();
1309
- if (entry) this.config.analyzeMotion(deviceId, entry.frame, entry.handle);
1332
+ if (!entry) continue;
1333
+ state.motionInFlight = true;
1334
+ Promise.resolve(this.config.analyzeMotion(deviceId, entry.frame, entry.handle)).catch(() => {}).finally(() => {
1335
+ state.motionInFlight = false;
1336
+ });
1310
1337
  }
1311
1338
  }
1312
1339
  async processWithSemaphore(deviceId, entry, frameInput, state, streamType) {
@@ -1375,12 +1402,568 @@ var PipelineRunner = class {
1375
1402
  }
1376
1403
  };
1377
1404
  //#endregion
1405
+ //#region src/session-decode/fork-decode-worker.ts
1406
+ /**
1407
+ * Real `fork` implementation for the session-decode coordinator — Epic C P1,
1408
+ * Task 4.
1409
+ *
1410
+ * Spawns the decode-worker child (`decode-worker-child.ts`, Task 2) as a
1411
+ * dedicated `child_process.fork` and adapts the returned `ChildProcess` to the
1412
+ * coordinator's minimal {@link ForkedChildLike} contract WITHOUT any cast —
1413
+ * an explicit forwarding wrapper, so a wider Node `ChildProcess` signature
1414
+ * never has to structurally satisfy the narrower interface.
1415
+ *
1416
+ * `serialization: 'advanced'` is MANDATORY: Node's default 'json' IPC
1417
+ * base64-serialises the `buffer` reply's `Uint8Array`, breaking the "raw
1418
+ * bytes, not base64" contract the coordinator + `worker-protocol.ts`'s
1419
+ * `value.bytes instanceof Uint8Array` guard both depend on. Advanced (V8
1420
+ * structured-clone) preserves the typed array on the wire.
1421
+ *
1422
+ * The child entry is resolved relative to THIS module's built location. Both
1423
+ * this module and `pipeline-runner/index.ts` bundle into the SAME entry
1424
+ * (`dist/pipeline-runner/index.js`, `manualChunks: () => null`), so
1425
+ * `import.meta.url` here is that file's URL and the sibling child lands at
1426
+ * `dist/session-decode/decode-worker-child.js` (its own vite build entry).
1427
+ */
1428
+ /** Absolute path to the built decode-worker child entry (a vite build entry). */
1429
+ var CHILD_ENTRY_PATH = (0, node_url.fileURLToPath)(new URL("../session-decode/decode-worker-child.js", require("url").pathToFileURL(__filename).href));
1430
+ /**
1431
+ * Known kill signals the coordinator may pass, mapped to their `NodeJS.Signals`
1432
+ * literal so `ChildProcess.kill` receives a correctly-typed signal without a
1433
+ * cast. The coordinator only ever sends the default (undefined → SIGTERM) and
1434
+ * the SIGKILL fallback; anything unrecognised degrades to a plain `kill()`.
1435
+ */
1436
+ var KILL_SIGNALS = {
1437
+ SIGKILL: "SIGKILL",
1438
+ SIGTERM: "SIGTERM"
1439
+ };
1440
+ /** Adapt a Node {@link ChildProcess} to the coordinator's {@link ForkedChildLike}. */
1441
+ function wrapChild(child) {
1442
+ return {
1443
+ send: (msg) => {
1444
+ child.send(msg);
1445
+ },
1446
+ on: (ev, cb) => {
1447
+ if (ev === "exit") child.on("exit", () => cb(void 0));
1448
+ else child.on("message", (m) => cb(m));
1449
+ },
1450
+ kill: (signal) => {
1451
+ if (signal === void 0) {
1452
+ child.kill();
1453
+ return;
1454
+ }
1455
+ const mapped = KILL_SIGNALS[signal];
1456
+ if (mapped) child.kill(mapped);
1457
+ else child.kill();
1458
+ }
1459
+ };
1460
+ }
1461
+ /**
1462
+ * Fork one decode-worker child. `source` is part of the coordinator's fork
1463
+ * signature (it forwards the same source in the subsequent `start` message);
1464
+ * the child entry itself is source-independent, so it is not consumed here.
1465
+ */
1466
+ function forkDecodeWorker(_source) {
1467
+ return wrapChild((0, node_child_process.fork)(CHILD_ENTRY_PATH, [], { serialization: "advanced" }));
1468
+ }
1469
+ //#endregion
1470
+ //#region src/session-decode/guarded-session-decode.ts
1471
+ /**
1472
+ * Guards the in-flight restream-acquire against a detach/unsubscribe race.
1473
+ *
1474
+ * `acquireSessionDecodeRestream` + forking the decode worker both take real
1475
+ * time (a broker round-trip, then a process fork). If the caller's teardown
1476
+ * runs while that acquire is still in flight, the closure that would release
1477
+ * the restream and stop the pump does not exist yet by the time the caller
1478
+ * stores it — a naive `await acquire(); return () => {...}` construction
1479
+ * leaks the acquired restream refcount and, once the acquire resolves, forks
1480
+ * an orphan decode worker nobody will ever stop.
1481
+ *
1482
+ * This helper returns the teardown SYNCHRONOUSLY. The acquire + pump-start
1483
+ * run in the background; an `aborted` flag recorded by the (possibly
1484
+ * already-called) teardown is checked right after the acquire resolves —
1485
+ * if set, the restream is released immediately and the pump is NEVER
1486
+ * started (no orphan worker). Otherwise the pump starts and the teardown,
1487
+ * once called, stops the pump then releases the restream.
1488
+ */
1489
+ function startGuardedSessionDecode(deps) {
1490
+ let aborted = false;
1491
+ let stopPump = null;
1492
+ let release = null;
1493
+ (async () => {
1494
+ const acquired = await deps.acquire();
1495
+ if (!acquired) return;
1496
+ if (aborted) {
1497
+ acquired.release();
1498
+ return;
1499
+ }
1500
+ release = acquired.release;
1501
+ stopPump = deps.startPump(acquired.source);
1502
+ })().catch((err) => {
1503
+ deps.logger.warn("session-decode: guarded start failed", { meta: { error: require_dist.errMsg(err) } });
1504
+ });
1505
+ return () => {
1506
+ aborted = true;
1507
+ stopPump?.();
1508
+ if (release) release();
1509
+ };
1510
+ }
1511
+ //#endregion
1512
+ //#region src/session-decode/session-decode-coordinator.ts
1513
+ /** How long to wait after `kill()` before escalating to `kill('SIGKILL')`. */
1514
+ var SIGKILL_FALLBACK_MS = 1e3;
1515
+ /**
1516
+ * Default timeout for the `pull` round-trip preceding the FIRST delivered
1517
+ * frame. Task 0 live-measured cold-start (restream dial + hwaccel context +
1518
+ * keyframe wait) at 5-8s, so this must tolerate a slow-but-healthy start.
1519
+ */
1520
+ var DEFAULT_FIRST_FRAME_TIMEOUT_MS = 2e4;
1521
+ /** Default timeout for every `pull` round-trip AFTER the first frame arrives. */
1522
+ var DEFAULT_FRAME_TIMEOUT_MS = 1e4;
1523
+ function toError(err) {
1524
+ return err instanceof Error ? err : new Error(String(err));
1525
+ }
1526
+ /**
1527
+ * Per-session mutable state + the message-handling logic that adapts the
1528
+ * child's `WorkerReply` stream into resolved/rejected waiter promises. One
1529
+ * instance is created per `generateVideoFrames()` call (i.e. per forked
1530
+ * child) — never shared across sessions.
1531
+ */
1532
+ var SessionDecodeSession = class {
1533
+ child;
1534
+ logger;
1535
+ firstFrameTimeoutMs;
1536
+ frameTimeoutMs;
1537
+ pendingPull = null;
1538
+ pendingToBuffers = /* @__PURE__ */ new Map();
1539
+ exited = false;
1540
+ sigkillTimer = null;
1541
+ pullTimeoutTimer = null;
1542
+ firstFrameReceived = false;
1543
+ constructor(child, logger, firstFrameTimeoutMs, frameTimeoutMs) {
1544
+ this.child = child;
1545
+ this.logger = logger;
1546
+ this.firstFrameTimeoutMs = firstFrameTimeoutMs;
1547
+ this.frameTimeoutMs = frameTimeoutMs;
1548
+ this.child.on("message", (m) => this.handleMessage(m));
1549
+ this.child.on("exit", () => this.handleExit());
1550
+ }
1551
+ start(source, opts) {
1552
+ this.trySend({
1553
+ kind: "start",
1554
+ source,
1555
+ opts
1556
+ });
1557
+ }
1558
+ /**
1559
+ * Send `pull` and resolve with the next `frame` or `ended` reply. Arms a
1560
+ * bounded liveness timeout for the round-trip: if the child stops
1561
+ * producing frames WITHOUT exiting, the timeout settles this pull as
1562
+ * `ended` so the caller's async generator resumes, runs its `finally`,
1563
+ * and tears the child down — see {@link handlePullTimeout}.
1564
+ */
1565
+ pullNext() {
1566
+ if (this.exited) return Promise.resolve({ kind: "ended" });
1567
+ return new Promise((resolve, reject) => {
1568
+ this.pendingPull = {
1569
+ resolve,
1570
+ reject
1571
+ };
1572
+ this.armPullTimeout();
1573
+ try {
1574
+ this.child.send({ kind: "pull" });
1575
+ } catch (err) {
1576
+ this.clearPullTimeout();
1577
+ this.pendingPull = null;
1578
+ reject(toError(err));
1579
+ }
1580
+ });
1581
+ }
1582
+ /** Send `toBuffer` for `frameId` and resolve with the matching `buffer` reply's bytes. */
1583
+ requestBuffer(frameId, opts) {
1584
+ if (this.exited) return Promise.reject(/* @__PURE__ */ new Error(`session-decode-coordinator: worker already exited (frameId=${frameId})`));
1585
+ return new Promise((resolve, reject) => {
1586
+ const waiter = {
1587
+ resolve,
1588
+ reject
1589
+ };
1590
+ this.queueToBufferWaiter(frameId, waiter);
1591
+ try {
1592
+ this.child.send({
1593
+ kind: "toBuffer",
1594
+ frameId,
1595
+ opts
1596
+ });
1597
+ } catch (err) {
1598
+ this.removeToBufferWaiter(frameId, waiter);
1599
+ reject(toError(err));
1600
+ }
1601
+ });
1602
+ }
1603
+ /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
1604
+ teardown() {
1605
+ this.clearPullTimeout();
1606
+ this.trySend({ kind: "stop" });
1607
+ this.tryKill(void 0);
1608
+ if (this.exited) return;
1609
+ this.sigkillTimer = setTimeout(() => {
1610
+ this.sigkillTimer = null;
1611
+ if (this.exited) return;
1612
+ this.logger.warn("session-decode-coordinator: child did not exit within SIGKILL fallback window — forcing", { meta: { fallbackMs: SIGKILL_FALLBACK_MS } });
1613
+ this.tryKill("SIGKILL");
1614
+ }, SIGKILL_FALLBACK_MS);
1615
+ this.sigkillTimer.unref?.();
1616
+ }
1617
+ handleMessage(raw) {
1618
+ if (!require_worker_protocol.isWorkerReply(raw)) {
1619
+ this.logger.warn("session-decode-coordinator: dropping malformed worker reply", { meta: { raw } });
1620
+ return;
1621
+ }
1622
+ switch (raw.kind) {
1623
+ case "frame":
1624
+ this.settlePendingPull({
1625
+ kind: "frame",
1626
+ reply: raw
1627
+ });
1628
+ return;
1629
+ case "ended":
1630
+ this.settlePendingPull({ kind: "ended" });
1631
+ return;
1632
+ case "buffer":
1633
+ this.settleToBuffer(raw.frameId, raw.bytes);
1634
+ return;
1635
+ case "error":
1636
+ this.settleError(raw.message, raw.frameId);
1637
+ return;
1638
+ }
1639
+ }
1640
+ handleExit() {
1641
+ this.exited = true;
1642
+ if (this.sigkillTimer) {
1643
+ clearTimeout(this.sigkillTimer);
1644
+ this.sigkillTimer = null;
1645
+ }
1646
+ this.clearPullTimeout();
1647
+ if (this.pendingPull) {
1648
+ const waiter = this.pendingPull;
1649
+ this.pendingPull = null;
1650
+ waiter.resolve({ kind: "ended" });
1651
+ }
1652
+ for (const waiters of this.pendingToBuffers.values()) for (const waiter of waiters) waiter.reject(/* @__PURE__ */ new Error("session-decode-coordinator: worker exited before toBuffer reply"));
1653
+ this.pendingToBuffers.clear();
1654
+ }
1655
+ settlePendingPull(outcome) {
1656
+ const waiter = this.pendingPull;
1657
+ if (!waiter) {
1658
+ this.logger.debug("session-decode-coordinator: dropping unsolicited pull-shaped reply", { meta: { outcomeKind: outcome.kind } });
1659
+ return;
1660
+ }
1661
+ this.pendingPull = null;
1662
+ this.clearPullTimeout();
1663
+ if (outcome.kind === "frame") this.firstFrameReceived = true;
1664
+ waiter.resolve(outcome);
1665
+ }
1666
+ /**
1667
+ * Fires when a `pull` round-trip outlives its liveness timeout — the child
1668
+ * stopped producing frames WITHOUT exiting (a hung decode). Settles the
1669
+ * outstanding pull as `ended` (not a throw) so the consumer's async
1670
+ * generator resumes cleanly, runs its `finally`, and tears the child down
1671
+ * via {@link teardown} — which sends `stop`, `kill()`s, and arms the 1s
1672
+ * SIGKILL fallback.
1673
+ */
1674
+ handlePullTimeout(timeoutMs) {
1675
+ this.pullTimeoutTimer = null;
1676
+ const waiter = this.pendingPull;
1677
+ if (!waiter) return;
1678
+ this.pendingPull = null;
1679
+ this.logger.warn(`session-decode-coordinator: decode worker produced no frame within ${timeoutMs}ms — ending session`, { meta: {
1680
+ timeoutMs,
1681
+ firstFrameReceived: this.firstFrameReceived
1682
+ } });
1683
+ waiter.resolve({ kind: "ended" });
1684
+ }
1685
+ armPullTimeout() {
1686
+ const timeoutMs = this.firstFrameReceived ? this.frameTimeoutMs : this.firstFrameTimeoutMs;
1687
+ this.pullTimeoutTimer = setTimeout(() => this.handlePullTimeout(timeoutMs), timeoutMs);
1688
+ this.pullTimeoutTimer.unref?.();
1689
+ }
1690
+ clearPullTimeout() {
1691
+ if (this.pullTimeoutTimer) {
1692
+ clearTimeout(this.pullTimeoutTimer);
1693
+ this.pullTimeoutTimer = null;
1694
+ }
1695
+ }
1696
+ /**
1697
+ * Correlate by `frameId`: a `buffer` reply only ever settles waiters queued
1698
+ * under the SAME frameId — a late reply for a stale/superseded frame that
1699
+ * nobody is (still) awaiting is dropped, never mistakenly resolves a
1700
+ * different, newer frame's waiter.
1701
+ */
1702
+ settleToBuffer(frameId, bytes) {
1703
+ const queue = this.pendingToBuffers.get(frameId);
1704
+ if (!queue || queue.length === 0) {
1705
+ this.logger.debug("session-decode-coordinator: dropping stale/unknown buffer reply", { meta: { frameId } });
1706
+ return;
1707
+ }
1708
+ const waiter = queue.shift();
1709
+ if (queue.length === 0) this.pendingToBuffers.delete(frameId);
1710
+ waiter?.resolve(bytes);
1711
+ }
1712
+ /**
1713
+ * `error` replies MAY carry a `frameId` (a `toBuffer`-scoped miss, e.g. a
1714
+ * stale/unknown frameId or a scale failure in `decode-worker-child.ts`) —
1715
+ * those fail ONLY that frame's `toBuffer` waiter(s) so the pull stays alive
1716
+ * and the generator keeps running (P2/I1: a single dropped frame must not
1717
+ * tear the whole session down). An error WITHOUT a `frameId` is a
1718
+ * session-level failure (decode-loop crash, dial error) and keeps the
1719
+ * original fatal behavior: fail the outstanding `pull` first (the more
1720
+ * common source), else fail every outstanding `toBuffer` waiter rather than
1721
+ * silently dropping the error and leaving a caller hanging forever.
1722
+ */
1723
+ settleError(message, frameId) {
1724
+ const err = /* @__PURE__ */ new Error(`session-decode-coordinator: worker error — ${message}`);
1725
+ if (frameId !== void 0) {
1726
+ const queue = this.pendingToBuffers.get(frameId);
1727
+ if (queue && queue.length > 0) {
1728
+ for (const waiter of queue) waiter.reject(err);
1729
+ this.pendingToBuffers.delete(frameId);
1730
+ return;
1731
+ }
1732
+ this.logger.debug("session-decode-coordinator: dropping toBuffer error for unknown frameId", { meta: { frameId } });
1733
+ return;
1734
+ }
1735
+ if (this.pendingPull) {
1736
+ const waiter = this.pendingPull;
1737
+ this.pendingPull = null;
1738
+ this.clearPullTimeout();
1739
+ waiter.reject(err);
1740
+ return;
1741
+ }
1742
+ for (const waiters of this.pendingToBuffers.values()) for (const waiter of waiters) waiter.reject(err);
1743
+ this.pendingToBuffers.clear();
1744
+ }
1745
+ queueToBufferWaiter(frameId, waiter) {
1746
+ const queue = this.pendingToBuffers.get(frameId);
1747
+ if (queue) queue.push(waiter);
1748
+ else this.pendingToBuffers.set(frameId, [waiter]);
1749
+ }
1750
+ removeToBufferWaiter(frameId, waiter) {
1751
+ const queue = this.pendingToBuffers.get(frameId);
1752
+ if (!queue) return;
1753
+ const idx = queue.indexOf(waiter);
1754
+ if (idx >= 0) queue.splice(idx, 1);
1755
+ if (queue.length === 0) this.pendingToBuffers.delete(frameId);
1756
+ }
1757
+ trySend(msg) {
1758
+ try {
1759
+ this.child.send(msg);
1760
+ } catch (err) {
1761
+ this.logger.warn("session-decode-coordinator: send to child failed", { meta: {
1762
+ requestKind: msg.kind,
1763
+ err: toError(err).message
1764
+ } });
1765
+ }
1766
+ }
1767
+ tryKill(signal) {
1768
+ try {
1769
+ this.child.kill(signal);
1770
+ } catch (err) {
1771
+ this.logger.warn("session-decode-coordinator: kill() on child failed", { meta: {
1772
+ signal,
1773
+ err: toError(err).message
1774
+ } });
1775
+ }
1776
+ }
1777
+ };
1778
+ /** Build the {@link FrameImage} adapter for one `frame` reply. */
1779
+ function buildFrameImage(session, reply) {
1780
+ let closed = false;
1781
+ return {
1782
+ width: reply.width,
1783
+ height: reply.height,
1784
+ format: reply.format,
1785
+ toBuffer: async (opts) => {
1786
+ if (closed) throw new Error(`session-decode-coordinator: toBuffer() called on a closed frame (frameId=${reply.frameId})`);
1787
+ return session.requestBuffer(reply.frameId, opts);
1788
+ },
1789
+ close: () => {
1790
+ closed = true;
1791
+ }
1792
+ };
1793
+ }
1794
+ /**
1795
+ * Create the `VideoFrameGenerator` adapter: forks one decode-worker child per
1796
+ * `generateVideoFrames()` call, drives it with `pull`/`toBuffer` requests
1797
+ * one at a time (no pre-fetch — back-pressure is the async generator's
1798
+ * natural suspend-on-yield), and guarantees the child is torn down
1799
+ * (`stop` + `kill()` + SIGKILL fallback) in every exit path via `finally`.
1800
+ */
1801
+ function createSessionDecodeCoordinator(deps) {
1802
+ const firstFrameTimeoutMs = deps.firstFrameTimeoutMs ?? DEFAULT_FIRST_FRAME_TIMEOUT_MS;
1803
+ const frameTimeoutMs = deps.frameTimeoutMs ?? DEFAULT_FRAME_TIMEOUT_MS;
1804
+ return { async *generateVideoFrames(source, opts) {
1805
+ const session = new SessionDecodeSession(deps.fork(source), deps.logger, firstFrameTimeoutMs, frameTimeoutMs);
1806
+ session.start(source, opts);
1807
+ try {
1808
+ while (true) {
1809
+ const outcome = await session.pullNext();
1810
+ if (outcome.kind === "ended") return;
1811
+ const { reply } = outcome;
1812
+ yield {
1813
+ timestamp: reply.timestamp,
1814
+ image: buildFrameImage(session, reply)
1815
+ };
1816
+ }
1817
+ } finally {
1818
+ session.teardown();
1819
+ }
1820
+ } };
1821
+ }
1822
+ //#endregion
1823
+ //#region src/session-decode/session-decode-pump.ts
1824
+ /**
1825
+ * Pure gate: does this runner route camera decode through a per-session
1826
+ * decode worker (Epic C) instead of the shm frame-handle poller? This is now
1827
+ * a RUNNER-LEVEL setting (the pipeline-runner addon's own config,
1828
+ * `this.config.useSessionDecode`) rather than a per-camera one — every
1829
+ * camera attached to this node shares the same decode path. DEFAULT TRUE:
1830
+ * the caller passes the runner's own `useSessionDecode` config value;
1831
+ * an operator unchecks it (node-wide) to fall back to the shared shm
1832
+ * frame-handle poller for every camera on this node.
1833
+ */
1834
+ function shouldUseSessionDecode(enabled) {
1835
+ return enabled === true;
1836
+ }
1837
+ /**
1838
+ * Detection working width cap — mirrors the shm decoder's
1839
+ * `initScaler` (`nodeav-decoder-session.ts`: `maxW = floor(640 / scale)`,
1840
+ * `scale = 1` default). The worker decodes full source resolution (e.g. a 4K
1841
+ * `native:main` stream); without this downscale `toBuffer` would ship a full
1842
+ * 3840×2160 rgb frame (~24 MB) over IPC per pull, which the detection path
1843
+ * cannot keep up with. Aspect ratio is preserved. A source already ≤ this
1844
+ * width (e.g. the small `native:sub` motion stream) is passed through
1845
+ * unscaled, exactly as the shm scaler's `min(srcW, maxW)` no-ops.
1846
+ */
1847
+ var WORKING_MAX_WIDTH = 640;
1848
+ /** Aspect-preserving cap to {@link WORKING_MAX_WIDTH}; `undefined` when no downscale is needed. */
1849
+ function resolveWorkingResize(width, height) {
1850
+ if (width <= WORKING_MAX_WIDTH || width <= 0) return void 0;
1851
+ const outWidth = WORKING_MAX_WIDTH;
1852
+ return {
1853
+ width: outWidth,
1854
+ height: Math.round(outWidth * height / width)
1855
+ };
1856
+ }
1857
+ /**
1858
+ * Build a {@link DecodedFrame} from one pulled {@link VideoFrame}. Downscales to
1859
+ * the detection working width (see {@link resolveWorkingResize}) so the ROI
1860
+ * scaler ships a small frame instead of the full source resolution.
1861
+ * `capturedAt` is set to `Date.now()` so downstream frameAge metrics diff
1862
+ * against the wall clock (mirrors the shm-ring source, which stamps commit
1863
+ * time) — this is what lets Step 5 MEASURE session-decode frameAge vs the
1864
+ * shm-ring cameras.
1865
+ */
1866
+ async function toDecodedFrame(frame, format) {
1867
+ const resize = resolveWorkingResize(frame.image.width, frame.image.height);
1868
+ const bytes = await frame.image.toBuffer({
1869
+ format,
1870
+ ...resize ? { resize } : {}
1871
+ });
1872
+ return {
1873
+ data: Buffer.from(bytes),
1874
+ width: resize?.width ?? frame.image.width,
1875
+ height: resize?.height ?? frame.image.height,
1876
+ format,
1877
+ timestamp: frame.timestamp,
1878
+ capturedAt: Date.now()
1879
+ };
1880
+ }
1881
+ /**
1882
+ * Start pulling frames from a per-session decode worker and feed each decoded
1883
+ * frame to `onDecodedFrame`. Returns an idempotent teardown closure that stops
1884
+ * the async generator (`it.return()`) — which triggers the coordinator's
1885
+ * `finally` → `stop` + `kill()` + 1s SIGKILL fallback → worker exit → the OS
1886
+ * reclaims the VAAPI/GPU decode pool. Fire-and-forget: the returned closure is
1887
+ * synchronous; the frame loop and generator teardown run on their own tasks.
1888
+ */
1889
+ function startSessionDecodePump(deps) {
1890
+ const format = deps.opts.format ?? "rgb";
1891
+ const iterator = createSessionDecodeCoordinator({
1892
+ fork: deps.fork,
1893
+ logger: deps.logger,
1894
+ ...deps.firstFrameTimeoutMs !== void 0 ? { firstFrameTimeoutMs: deps.firstFrameTimeoutMs } : {},
1895
+ ...deps.frameTimeoutMs !== void 0 ? { frameTimeoutMs: deps.frameTimeoutMs } : {}
1896
+ }).generateVideoFrames(deps.source, deps.opts);
1897
+ let stopped = false;
1898
+ let releaseSpawnSlot = null;
1899
+ let spawnSlotReleased = false;
1900
+ const releaseSpawnSlotOnce = () => {
1901
+ if (spawnSlotReleased) return;
1902
+ const release = releaseSpawnSlot;
1903
+ if (!release) return;
1904
+ spawnSlotReleased = true;
1905
+ releaseSpawnSlot = null;
1906
+ release();
1907
+ };
1908
+ const pump = async () => {
1909
+ try {
1910
+ if (deps.acquireSpawnSlot) {
1911
+ releaseSpawnSlot = await deps.acquireSpawnSlot();
1912
+ if (stopped) {
1913
+ releaseSpawnSlotOnce();
1914
+ return;
1915
+ }
1916
+ }
1917
+ let firstFrameSeen = false;
1918
+ for await (const frame of iterator) {
1919
+ if (!firstFrameSeen) {
1920
+ firstFrameSeen = true;
1921
+ releaseSpawnSlotOnce();
1922
+ }
1923
+ if (stopped) {
1924
+ frame.image.close();
1925
+ break;
1926
+ }
1927
+ try {
1928
+ const decoded = await toDecodedFrame(frame, format);
1929
+ deps.onDecodedFrame(decoded);
1930
+ } catch (err) {
1931
+ deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", { meta: {
1932
+ deviceId: deps.source.deviceId,
1933
+ format,
1934
+ error: require_dist.errMsg(err)
1935
+ } });
1936
+ } finally {
1937
+ frame.image.close();
1938
+ }
1939
+ }
1940
+ } catch (err) {
1941
+ deps.logger.warn("session-decode pump: frame loop ended with error", { meta: {
1942
+ deviceId: deps.source.deviceId,
1943
+ format,
1944
+ error: require_dist.errMsg(err)
1945
+ } });
1946
+ } finally {
1947
+ releaseSpawnSlotOnce();
1948
+ }
1949
+ };
1950
+ pump();
1951
+ return () => {
1952
+ if (stopped) return;
1953
+ stopped = true;
1954
+ releaseSpawnSlotOnce();
1955
+ iterator.return().catch(() => {});
1956
+ };
1957
+ }
1958
+ //#endregion
1378
1959
  //#region src/pipeline-runner/index.ts
1379
1960
  var DEFAULT_CONFIG = {
1380
1961
  maxQueueDepth: 30,
1381
1962
  maxConcurrentInferences: 16,
1382
1963
  targetLoadPercent: 80,
1383
- minThrottledFps: 1
1964
+ minThrottledFps: 1,
1965
+ useSessionDecode: true,
1966
+ maxConcurrentDecodeSpawns: 3
1384
1967
  };
1385
1968
  /**
1386
1969
  * Pure decision helper: returns `true` when a dynamic frame-diff analyzer
@@ -1458,6 +2041,19 @@ function resolveMotionFrameSource(config, sharedWithDetection) {
1458
2041
  };
1459
2042
  }
1460
2043
  /**
2044
+ * The owner hostname a session-decode worker must dial to reach this camera's
2045
+ * compressed restream. `undefined` (→ the broker returns a `127.0.0.1` URL,
2046
+ * correct for a node-local co-located worker) unless the camera's source is a
2047
+ * cross-node remote-restream, in which case the owner host is resolved exactly
2048
+ * as `remoteSourceFor`/`RemoteSourcePlane` do. Pure so the decision is
2049
+ * unit-testable without the addon/broker.
2050
+ */
2051
+ function resolveSessionDecodeHostname(config, hubUrl) {
2052
+ const frameSource = config.frameSource;
2053
+ if (!isRemoteRestream(frameSource)) return void 0;
2054
+ return require_hub_hostname.resolveHubHostname(frameSource.hubHostnameOverride, hubUrl);
2055
+ }
2056
+ /**
1461
2057
  * Interval for periodic metric snapshot emission. ~1 Hz strikes the
1462
2058
  * balance between UI freshness (overlay phase / fps numbers feel live)
1463
2059
  * and bus traffic (one snapshot per attached camera + one per node
@@ -1474,6 +2070,15 @@ var METRICS_SNAPSHOT_INTERVAL_MS = 1e3;
1474
2070
  var METRICS_HEARTBEAT_MS = 3e4;
1475
2071
  var PipelineRunnerAddon = class extends require_dist.BaseAddon {
1476
2072
  runner = null;
2073
+ /**
2074
+ * Per-runner (per-node) decode-worker SPAWN-CONCURRENCY GATE (P5). A single
2075
+ * counting semaphore every session-decode fork (detection + motion, all
2076
+ * cameras on this node) passes through, sized to
2077
+ * `config.maxConcurrentDecodeSpawns`. Bounds a synchronized multi-camera
2078
+ * motion burst to N concurrent forks instead of stampeding the shared iGPU.
2079
+ * `resize()`d live in `onConfigChanged`. Null until `onInitialize`.
2080
+ */
2081
+ spawnGate = null;
1477
2082
  attached = /* @__PURE__ */ new Map();
1478
2083
  nodeId = "unknown";
1479
2084
  metricsSnapshotTimer = null;
@@ -1512,6 +2117,14 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
1512
2117
  */
1513
2118
  remotePlanes = /* @__PURE__ */ new Map();
1514
2119
  /**
2120
+ * Devices whose motion currently rides the detection session-decode worker
2121
+ * (P4 co-active sharing) instead of a dedicated motion worker. Membership →
2122
+ * the detection pump also enqueues a derived gray motion frame per decoded
2123
+ * rgb (see `enqueueSharedDetectionFrame`). A pure flag-set: `enqueueMotionFrame`
2124
+ * is device-scoped, so no per-device callback object is needed.
2125
+ */
2126
+ sharedMotionSinks = /* @__PURE__ */ new Set();
2127
+ /**
1515
2128
  * Snapshot-equality cache for metrics-snapshot defer. The runner
1516
2129
  * fires per-camera metrics every `METRICS_SNAPSHOT_INTERVAL_MS`;
1517
2130
  * for an idle camera (no inference, queue empty, fps=0) every tick
@@ -1532,7 +2145,25 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
1532
2145
  */
1533
2146
  benchFrameCache = /* @__PURE__ */ new Map();
1534
2147
  benchFrameSweeper = null;
2148
+ /** Per-camera single-flight: a camera never runs two occupancy bursts at once. */
1535
2149
  occupancyBurstInFlight = /* @__PURE__ */ new Set();
2150
+ /**
2151
+ * GLOBAL occupancy admission queue. Each occupancy burst SUBSCRIBES a detection
2152
+ * stream (rgb) which, during the `watching` phase, has no existing consumer and
2153
+ * therefore makes the broker DIAL a fresh decoder. Without a global cap, N
2154
+ * cameras rechecking on the same interval would spawn N parallel decoder dials
2155
+ * (thundering herd → re-dial churn → latency). This bounds concurrent bursts to
2156
+ * OCCUPANCY_MAX_CONCURRENT; the rest wait in the queue and drain as slots free.
2157
+ * (A burst on an already-`active` camera reuses the live decoder — the broker's
2158
+ * demand-gate shares one session across subscribers — so only watching-phase
2159
+ * spawns pay the dial cost.)
2160
+ */
2161
+ occupancyQueue = [];
2162
+ occupancyQueued = /* @__PURE__ */ new Set();
2163
+ occupancyFrames = /* @__PURE__ */ new Map();
2164
+ occupancyActiveCount = 0;
2165
+ /** Max concurrent occupancy bursts (decoder dials) across all cameras. */
2166
+ occupancyMaxConcurrent = 3;
1536
2167
  constructor() {
1537
2168
  super({ ...DEFAULT_CONFIG });
1538
2169
  }
@@ -1549,9 +2180,10 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
1549
2180
  onPhaseChanged: (deviceId, phase, meta) => this.handlePhaseChanged(deviceId, phase, meta),
1550
2181
  logger: this.ctx.logger,
1551
2182
  onOccupancyRecheck: (deviceId, frames) => {
1552
- this.runOccupancyBurst(deviceId, frames);
2183
+ this.enqueueOccupancyBurst(deviceId, frames);
1553
2184
  }
1554
2185
  });
2186
+ this.spawnGate = new Semaphore(Math.max(1, this.config.maxConcurrentDecodeSpawns));
1555
2187
  this.runner.timingSampler.setLogger(this.ctx.logger.child("timing"));
1556
2188
  this.runner.onDetectionStreamChange((deviceId, action) => {
1557
2189
  this.handleDetectionStreamChange(deviceId, action);
@@ -1970,6 +2602,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
1970
2602
  detectionMode: config.detectionMode,
1971
2603
  fps: config.detectionFps,
1972
2604
  motionCooldownMs: config.motionCooldownMs,
2605
+ occupancyRecheckEnabled: config.occupancyRecheckEnabled,
1973
2606
  occupancyRecheckSec: config.occupancyRecheckSec,
1974
2607
  occupancyRecheckFrames: config.occupancyRecheckFrames
1975
2608
  });
@@ -2190,6 +2823,196 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2190
2823
  return plane;
2191
2824
  }
2192
2825
  /**
2826
+ * Acquire the broker's COMPRESSED passthrough restream for one camStream and
2827
+ * wrap it as a {@link VideoFrameSource} the decode worker can dial. Mirrors
2828
+ * {@link RemoteSourcePlane}'s acquire: resolve the profile the camStream
2829
+ * feeds, then `getStreamWithCodec({ video: 'copy' })` (refcounted — rides the
2830
+ * broker's single source pull, never a second camera dial). No `hostname`:
2831
+ * the P1 flagged camera is HUB-LOCAL, so the returned `127.0.0.1` URL is
2832
+ * dialable from this hub-resident runner. Returns `null` (loudly logged) when
2833
+ * no profile is bound yet — the caller opens no subscription.
2834
+ */
2835
+ async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname) {
2836
+ const profile = profileForStreamId(await api.streamBroker.listAllProfileSlots.query(), deviceId, streamId);
2837
+ if (profile === null) {
2838
+ this.ctx.logger.error("session-decode: no broker profile feeds camStream — cannot acquire restream", {
2839
+ tags: { deviceId },
2840
+ meta: { streamId }
2841
+ });
2842
+ return null;
2843
+ }
2844
+ const rtpSource = await api.streamBroker.getStreamWithCodec.mutate({
2845
+ deviceId,
2846
+ video: "copy",
2847
+ audio: "none",
2848
+ profile,
2849
+ tag,
2850
+ ...hostname !== void 0 ? { hostname } : {}
2851
+ });
2852
+ const codec = rtpSource.videoCodec === "H265" ? "h265" : "h264";
2853
+ const source = {
2854
+ restreamUrl: rtpSource.url,
2855
+ deviceId,
2856
+ codec
2857
+ };
2858
+ const pipelineKey = rtpSource.pipelineKey;
2859
+ let released = false;
2860
+ const release = async () => {
2861
+ if (released) return;
2862
+ released = true;
2863
+ await api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey }).catch((err) => {
2864
+ this.ctx.logger.debug("session-decode: releaseStreamWithCodec failed (benign on hub restart)", {
2865
+ tags: { deviceId },
2866
+ meta: {
2867
+ pipelineKey,
2868
+ error: require_dist.errMsg(err)
2869
+ }
2870
+ });
2871
+ });
2872
+ };
2873
+ return {
2874
+ source,
2875
+ release
2876
+ };
2877
+ }
2878
+ /**
2879
+ * Register this device's motion to ride the detection session-decode worker
2880
+ * (P4 co-active sharing) instead of forking a dedicated motion worker. While
2881
+ * registered, the detection pump enqueues a derived gray motion frame per
2882
+ * decoded rgb. Returns an idempotent teardown that unregisters it; forks
2883
+ * nothing and holds no restream (the detection subscription owns the worker).
2884
+ */
2885
+ registerSharedMotionSink(deviceId) {
2886
+ this.sharedMotionSinks.add(deviceId);
2887
+ this.ctxIfReady?.logger.info("session-decode: motion rides the detection worker (shared — no 2nd fork)", { tags: { deviceId } });
2888
+ let released = false;
2889
+ return () => {
2890
+ if (released) return;
2891
+ released = true;
2892
+ this.sharedMotionSinks.delete(deviceId);
2893
+ };
2894
+ }
2895
+ /**
2896
+ * Fan a decoded detection rgb frame out to the detection queue, and — when
2897
+ * this device's motion is riding the detection worker (P4 sharing) — also to
2898
+ * the motion queue as a derived gray frame (`rgbFrameToGrayFrame`, luma only:
2899
+ * no second dial, no second decode, no GPU). Mirrors the shm shared-motion
2900
+ * seam (`subscribeMotionFrames` `shared` branch).
2901
+ */
2902
+ enqueueSharedDetectionFrame(deviceId, frame) {
2903
+ const runner = this.runner;
2904
+ if (!runner) return;
2905
+ runner.enqueueDetectionFrame(deviceId, frame, void 0);
2906
+ if (this.sharedMotionSinks.has(deviceId)) runner.enqueueMotionFrame(deviceId, rgbFrameToGrayFrame(frame), void 0);
2907
+ }
2908
+ /**
2909
+ * Epic C P1 detection path for a flagged camera: decode `rgb` in a
2910
+ * per-session forked worker and feed the runner's detection queue INLINE (no
2911
+ * `FrameHandle`), exactly like the shared shm path. Teardown stops the pump
2912
+ * (→ worker `stop` + `kill` + SIGKILL fallback → reclaim) and releases the
2913
+ * restream refcount.
2914
+ *
2915
+ * Returns the teardown SYNCHRONOUSLY (Epic C P2 I3): the restream acquire +
2916
+ * pump-start run in the background via {@link startGuardedSessionDecode},
2917
+ * so a detach that lands while the acquire is still in flight releases the
2918
+ * restream immediately and never forks the decode worker — no orphan
2919
+ * worker, no leaked refcount.
2920
+ */
2921
+ /**
2922
+ * Build the spawn-gate acquire the pump calls before forking a decode
2923
+ * worker (P5). Logs DEFERRAL (gate saturated → this fork queues) and the
2924
+ * subsequent proceed — no silent capping. Returns a no-op acquire when the
2925
+ * gate is not yet initialised (pre-`onInitialize`), so the pump is never
2926
+ * blocked by a missing gate. `label` distinguishes detect vs motion forks in
2927
+ * the logs; the device id is already tagged on `log`.
2928
+ */
2929
+ buildDecodeSpawnAcquire(log, label) {
2930
+ return async () => {
2931
+ const gate = this.spawnGate;
2932
+ if (!gate) return () => {};
2933
+ const wasDeferred = gate.available <= 0;
2934
+ if (wasDeferred) log.info("session-decode: decode-worker spawn DEFERRED — spawn gate saturated, queuing", { meta: {
2935
+ label,
2936
+ cap: gate.concurrency,
2937
+ inFlight: gate.concurrency - gate.available
2938
+ } });
2939
+ const release = await gate.acquire();
2940
+ if (wasDeferred) log.info("session-decode: deferred decode-worker spawn now proceeding", { meta: { label } });
2941
+ else log.debug("session-decode: decode-worker spawn slot acquired", { meta: {
2942
+ label,
2943
+ remaining: gate.available
2944
+ } });
2945
+ return release;
2946
+ };
2947
+ }
2948
+ startDetectionSessionDecode(config, api) {
2949
+ const runner = this.runner;
2950
+ const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
2951
+ if (!runner) return () => {};
2952
+ return startGuardedSessionDecode({
2953
+ logger: log,
2954
+ acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:detect:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"])),
2955
+ startPump: (source) => {
2956
+ log.info("session-decode: detection routed through per-session decode worker (rgb)", { meta: {
2957
+ streamId: config.detectionStreamId,
2958
+ codec: source.codec
2959
+ } });
2960
+ return startSessionDecodePump({
2961
+ fork: forkDecodeWorker,
2962
+ logger: log,
2963
+ source,
2964
+ opts: {
2965
+ fps: config.detectionFps,
2966
+ format: "rgb"
2967
+ },
2968
+ acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "detect"),
2969
+ onDecodedFrame: (frame) => {
2970
+ this.enqueueSharedDetectionFrame(config.deviceId, frame);
2971
+ }
2972
+ });
2973
+ }
2974
+ });
2975
+ }
2976
+ /**
2977
+ * Epic C P1 motion path for a flagged camera: a dedicated `gray` decode of
2978
+ * the SUB stream in its OWN per-session forked worker (independent of the
2979
+ * detection worker — two workers per flagged camera in P1; sharing one is a
2980
+ * P2 follow-up). Feeds the motion queue INLINE (no handle). Teardown stops
2981
+ * the pump and releases the restream refcount.
2982
+ *
2983
+ * Returns the teardown SYNCHRONOUSLY (Epic C P2 I3) — same
2984
+ * {@link startGuardedSessionDecode} guard as detection: a detach during the
2985
+ * in-flight acquire releases immediately and skips the pump/worker fork.
2986
+ */
2987
+ startMotionSessionDecode(config, api) {
2988
+ const runner = this.runner;
2989
+ const log = this.ctx.logger.withTags({ deviceId: config.deviceId });
2990
+ if (!runner) return () => {};
2991
+ return startGuardedSessionDecode({
2992
+ logger: log,
2993
+ acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.motionStreamId, `session-decode:motion:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"])),
2994
+ startPump: (source) => {
2995
+ log.info("session-decode: motion routed through per-session decode worker (gray)", { meta: {
2996
+ streamId: config.motionStreamId,
2997
+ codec: source.codec
2998
+ } });
2999
+ return startSessionDecodePump({
3000
+ fork: forkDecodeWorker,
3001
+ logger: log,
3002
+ source,
3003
+ opts: {
3004
+ fps: config.motionFps,
3005
+ format: "gray"
3006
+ },
3007
+ acquireSpawnSlot: this.buildDecodeSpawnAcquire(log, "motion"),
3008
+ onDecodedFrame: (frame) => {
3009
+ runner.enqueueMotionFrame(config.deviceId, frame, void 0);
3010
+ }
3011
+ });
3012
+ }
3013
+ });
3014
+ }
3015
+ /**
2193
3016
  * Subscribe the motion analyzer to decoded frames.
2194
3017
  *
2195
3018
  * Two modes:
@@ -2213,6 +3036,10 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2213
3036
  log.warn("subscribeMotionFrames: this.ctx.api not available");
2214
3037
  return null;
2215
3038
  }
3039
+ if (shouldUseSessionDecode(this.config.useSessionDecode)) {
3040
+ if (resolveMotionFrameSource(config, opts?.sharedWithDetection === true).shared) return this.registerSharedMotionSink(config.deviceId);
3041
+ return this.startMotionSessionDecode(config, api);
3042
+ }
2216
3043
  const src = resolveMotionFrameSource(config, opts?.sharedWithDetection === true);
2217
3044
  const shared = src.shared;
2218
3045
  const streamId = src.streamId;
@@ -2305,6 +3132,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2305
3132
  log.warn("subscribeDetectionFrames: this.ctx.api not available");
2306
3133
  return null;
2307
3134
  }
3135
+ if (shouldUseSessionDecode(this.config.useSessionDecode)) return this.startDetectionSessionDecode(config, api);
2308
3136
  const remote = this.remoteSourceFor(config);
2309
3137
  return startFrameHandlePoller({
2310
3138
  api,
@@ -2440,6 +3268,35 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2440
3268
  log.error("runMotionAnalysis failed", { meta: { error: msg } });
2441
3269
  }
2442
3270
  }
3271
+ /**
3272
+ * Admit an occupancy recheck into the global queue. Deduped per camera (one
3273
+ * already queued or bursting is ignored; its latest frame-count wins) so a
3274
+ * fast recheck cadence can't stack requests. Draining is bounded by
3275
+ * `occupancyMaxConcurrent` so 25 cameras rechecking together don't spawn 25
3276
+ * parallel decoder dials.
3277
+ */
3278
+ enqueueOccupancyBurst(deviceId, frames) {
3279
+ this.occupancyFrames.set(deviceId, frames);
3280
+ if (this.occupancyBurstInFlight.has(deviceId) || this.occupancyQueued.has(deviceId)) return;
3281
+ this.occupancyQueued.add(deviceId);
3282
+ this.occupancyQueue.push(deviceId);
3283
+ this.drainOccupancyQueue();
3284
+ }
3285
+ /** Start queued occupancy bursts up to the global concurrency cap. */
3286
+ drainOccupancyQueue() {
3287
+ while (this.occupancyActiveCount < this.occupancyMaxConcurrent && this.occupancyQueue.length > 0) {
3288
+ const deviceId = this.occupancyQueue.shift();
3289
+ if (deviceId === void 0) break;
3290
+ this.occupancyQueued.delete(deviceId);
3291
+ const frames = this.occupancyFrames.get(deviceId) ?? 4;
3292
+ this.occupancyFrames.delete(deviceId);
3293
+ this.occupancyActiveCount++;
3294
+ this.runOccupancyBurst(deviceId, frames).finally(() => {
3295
+ this.occupancyActiveCount--;
3296
+ this.drainOccupancyQueue();
3297
+ });
3298
+ }
3299
+ }
2443
3300
  async runOccupancyBurst(deviceId, frames) {
2444
3301
  if (this.occupancyBurstInFlight.has(deviceId)) return;
2445
3302
  const attachment = this.attached.get(deviceId);
@@ -2635,21 +3492,44 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
2635
3492
  step: 1,
2636
3493
  default: DEFAULT_CONFIG.minThrottledFps,
2637
3494
  showValue: true
3495
+ },
3496
+ {
3497
+ type: "boolean",
3498
+ key: "useSessionDecode",
3499
+ style: "checkbox",
3500
+ label: "Use per-session decode workers",
3501
+ description: "Default decode path for every camera on this runner/node: each attached camera decodes in a disposable per-session worker process instead of the shared shm frame-handle poller, freeing the VAAPI/GPU decode pool (and any /dev/shm ring segments) as soon as it goes idle. Uncheck to fall this node back to the shared shm frame-handle poller for ALL its cameras — no per-camera override.",
3502
+ default: DEFAULT_CONFIG.useSessionDecode
3503
+ },
3504
+ {
3505
+ type: "slider",
3506
+ key: "maxConcurrentDecodeSpawns",
3507
+ label: "Max concurrent decode-worker spawns",
3508
+ description: "Min floor of per-session decode-worker forks allowed to spin up CONCURRENTLY on this node. A synchronized multi-camera motion burst forks at most this many decode workers at once; the rest queue (FIFO) until an in-flight worker delivers its first frame or tears down. Keeps a burst from stampeding the shared GPU. Set >= your camera count to effectively disable the gate. Only applies when per-session decode workers are enabled.",
3509
+ min: 1,
3510
+ max: 16,
3511
+ step: 1,
3512
+ default: DEFAULT_CONFIG.maxConcurrentDecodeSpawns,
3513
+ showValue: true
2638
3514
  }
2639
3515
  ]
2640
3516
  }] });
2641
3517
  }
2642
3518
  async onConfigChanged() {
2643
3519
  this.runner?.updateLimits(this.config);
3520
+ this.spawnGate?.resize(Math.max(1, this.config.maxConcurrentDecodeSpawns));
2644
3521
  this.ctx.logger.info("pipeline-runner tuning updated", { meta: {
2645
3522
  maxQueueDepth: this.config.maxQueueDepth,
2646
3523
  maxConcurrentInferences: this.config.maxConcurrentInferences,
2647
3524
  targetLoadPercent: this.config.targetLoadPercent,
2648
- minThrottledFps: this.config.minThrottledFps
3525
+ minThrottledFps: this.config.minThrottledFps,
3526
+ useSessionDecode: this.config.useSessionDecode,
3527
+ maxConcurrentDecodeSpawns: this.config.maxConcurrentDecodeSpawns
2649
3528
  } });
2650
3529
  }
2651
3530
  };
2652
3531
  //#endregion
3532
+ exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
2653
3533
  exports.FrameQueue = FrameQueue;
2654
3534
  exports.PipelineRunner = PipelineRunner;
2655
3535
  exports.PipelineTimingSampler = PipelineTimingSampler;
@@ -2657,5 +3537,6 @@ exports.Semaphore = Semaphore;
2657
3537
  exports.customActions = pipelineRunnerBenchActions;
2658
3538
  exports.default = PipelineRunnerAddon;
2659
3539
  exports.resolveMotionFrameSource = resolveMotionFrameSource;
3540
+ exports.resolveSessionDecodeHostname = resolveSessionDecodeHostname;
2660
3541
  exports.rgbFrameToGrayFrame = rgbFrameToGrayFrame;
2661
3542
  exports.shouldStartOnboardAnalyzer = shouldStartOnboardAnalyzer;