@camstack/addon-pipeline 1.1.39 → 1.1.41

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