@camstack/addon-pipeline 1.1.54 → 1.1.55
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.
- package/dist/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +149 -1468
- package/dist/detection-pipeline/index.mjs +129 -1448
- package/dist/{dist-BbaoC680.js → dist-DI57FC8K.js} +59 -3
- package/dist/{dist-CEcTeu1h.mjs → dist-RWGGPwVx.mjs} +59 -3
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +665 -23
- package/dist/pipeline-runner/index.mjs +665 -24
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/{remote-source-plane-vN45KFSm.js → remote-source-plane-CHgvzzA6.js} +1 -1
- package/dist/{remote-source-plane-CNCqC_XG.mjs → remote-source-plane-DU0aRSPv.mjs} +1 -1
- package/dist/session-decode/decode-worker-child.js +222 -3
- package/dist/session-decode/decode-worker-child.mjs +222 -3
- package/dist/step-definitions-CNBFKjZe.js +1514 -0
- package/dist/step-definitions-CP9kVSml.mjs +1479 -0
- package/dist/stream-broker/_stub.js +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-HS0tc6Sa.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-mDCPzmT3.mjs} +3 -3
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B_v5r5ya.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dn_pxzP-.mjs} +1 -1
- package/dist/stream-broker/{hostInit-DgohfEDA.mjs → hostInit-GM_CI22k.mjs} +3 -3
- package/dist/stream-broker/index.js +2 -2
- package/dist/stream-broker/index.mjs +2 -2
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DGIt_waM.mjs → worker-protocol-CyVJTZEO.mjs} +1 -0
- package/dist/{worker-protocol-DkL6GDxe.js → worker-protocol-PP4jKHHJ.js} +1 -0
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CA2Hd6xx.js → MaskShapeCanvas-DI4BY7W2-BChW0ntM.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DUN1VeC7.js → MotionZonesSettings-NcxxQN8r-DDzqEbSe.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BqZIAU5s.js → PrivacyMaskSettings-APgPLF7p-B879GXaf.js} +1 -1
- package/embed-dist/assets/{index-CMkYypxB.js → index-CNjQ5rAE.js} +10 -10
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { $ as boolean, D as nodePin, H as createEvent, I as errMsg, K as makeSourceBrokerId, L as BaseAddon, Q as array, V as EventCategory, Z as _enum, _ as customAction, it as object, k as pipelineRunnerCapability, ot as string, rt as number, tt as lazy, y as defineCustomActions } from "../dist-
|
|
2
|
-
import { n as isRemoteRestream, r as profileForStreamId, t as RemoteSourcePlane } from "../remote-source-plane-
|
|
1
|
+
import { $ as boolean, D as nodePin, H as createEvent, I as errMsg, K as makeSourceBrokerId, L as BaseAddon, Q as array, V as EventCategory, Z as _enum, _ as customAction, it as object, k as pipelineRunnerCapability, ot as string, rt as number, tt as lazy, y as defineCustomActions } from "../dist-RWGGPwVx.mjs";
|
|
2
|
+
import { n as isRemoteRestream, r as profileForStreamId, t as RemoteSourcePlane } from "../remote-source-plane-DU0aRSPv.mjs";
|
|
3
3
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
|
-
import { t as isWorkerReply } from "../worker-protocol-
|
|
4
|
+
import { t as isWorkerReply } from "../worker-protocol-CyVJTZEO.mjs";
|
|
5
|
+
import { a as getStepDefinition } from "../step-definitions-CP9kVSml.mjs";
|
|
5
6
|
import { DecoderFrameRingSink, FrameRingReaderCache, unlinkSegment } from "@camstack/shm-ring";
|
|
6
7
|
import { fork } from "node:child_process";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
@@ -423,6 +424,36 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
|
|
|
423
424
|
};
|
|
424
425
|
}
|
|
425
426
|
//#endregion
|
|
427
|
+
//#region src/pipeline-runner/frame-governor.ts
|
|
428
|
+
var FrameGovernor = class {
|
|
429
|
+
config;
|
|
430
|
+
intervals = /* @__PURE__ */ new Map();
|
|
431
|
+
throttled = 0;
|
|
432
|
+
constructor(config) {
|
|
433
|
+
this.config = config;
|
|
434
|
+
}
|
|
435
|
+
update(samples) {
|
|
436
|
+
this.intervals.clear();
|
|
437
|
+
this.throttled = 0;
|
|
438
|
+
if (samples.size === 0) return;
|
|
439
|
+
const shareMsPerSec = this.config.targetLoadPercent / 100 * this.config.maxConcurrentInferences * 1e3 / samples.size;
|
|
440
|
+
for (const [deviceId, s] of samples) {
|
|
441
|
+
const ceilingFps = s.configuredFps > 0 ? s.configuredFps : s.decodeFps;
|
|
442
|
+
const affordableFps = s.avgInferenceMs > 0 ? shareMsPerSec / s.avgInferenceMs : ceilingFps;
|
|
443
|
+
const targetFps = Math.min(ceilingFps, Math.max(this.config.minThrottledFps, affordableFps));
|
|
444
|
+
if (targetFps < ceilingFps) this.throttled++;
|
|
445
|
+
this.intervals.set(deviceId, targetFps > 0 ? 1e3 / targetFps : 0);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Minimum ms between admitted frames; 0 = no gate. */
|
|
449
|
+
minIntervalMs(deviceId) {
|
|
450
|
+
return this.intervals.get(deviceId) ?? 0;
|
|
451
|
+
}
|
|
452
|
+
throttledCount() {
|
|
453
|
+
return this.throttled;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
//#endregion
|
|
426
457
|
//#region src/pipeline-runner/frame-queue.ts
|
|
427
458
|
/**
|
|
428
459
|
* Latest-frame-only buffer. Keeps only the most recent item, dropping all
|
|
@@ -604,6 +635,24 @@ var PipelineTimingSampler = class {
|
|
|
604
635
|
if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
|
|
605
636
|
this.detSamples.get(deviceId).push(s);
|
|
606
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* Moving average inference time (ms) per camera over the current
|
|
640
|
+
* accumulation window — the same `inf` samples the `pipeline stats` log
|
|
641
|
+
* line aggregates (see {@link report}'s `inf` computation). Consumed by
|
|
642
|
+
* the {@link FrameGovernor} recompute tick as `CameraLoadSample.avgInferenceMs`.
|
|
643
|
+
* The window resets on every {@link report} call (every REPORT_INTERVAL_MS),
|
|
644
|
+
* so a camera with zero completed inferences since the last report is
|
|
645
|
+
* simply absent from the returned map — callers supply their own default.
|
|
646
|
+
*/
|
|
647
|
+
avgInferenceMsByCamera() {
|
|
648
|
+
const result = /* @__PURE__ */ new Map();
|
|
649
|
+
for (const [deviceId, samples] of this.detSamples) {
|
|
650
|
+
if (samples.length === 0) continue;
|
|
651
|
+
const total = samples.reduce((sum, s) => sum + s.inference, 0);
|
|
652
|
+
result.set(deviceId, total / samples.length);
|
|
653
|
+
}
|
|
654
|
+
return result;
|
|
655
|
+
}
|
|
607
656
|
addMotionSample(deviceId, ms, frameAge = -1) {
|
|
608
657
|
if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
|
|
609
658
|
this.motSamples.get(deviceId).push({
|
|
@@ -781,6 +830,8 @@ function ownMotionEntry(entry) {
|
|
|
781
830
|
*/
|
|
782
831
|
var HANDLE_PASSTHROUGH_MAX_QUEUE_DEPTH = 1;
|
|
783
832
|
var DEFAULT_MOTION_COOLDOWN_MS = 3e4;
|
|
833
|
+
var GOVERNOR_RECOMPUTE_INTERVAL_MS = 2e3;
|
|
834
|
+
var DEFAULT_DECODE_FPS = 25;
|
|
784
835
|
function toFrameInput$1(frame) {
|
|
785
836
|
return {
|
|
786
837
|
data: frame.data,
|
|
@@ -816,10 +867,20 @@ var PipelineRunner = class {
|
|
|
816
867
|
detectionStreamHandler = null;
|
|
817
868
|
logger;
|
|
818
869
|
timingSampler = new PipelineTimingSampler();
|
|
870
|
+
governor;
|
|
871
|
+
lastAdmittedAt = /* @__PURE__ */ new Map();
|
|
872
|
+
arrivalCounts = /* @__PURE__ */ new Map();
|
|
873
|
+
lastGovernorRecomputeAt = Date.now();
|
|
874
|
+
governorIntervalHandle = null;
|
|
819
875
|
constructor(config) {
|
|
820
876
|
this.config = config;
|
|
821
877
|
this.logger = config.logger;
|
|
822
878
|
this.semaphore = new Semaphore(config.maxConcurrentInferences);
|
|
879
|
+
this.governor = new FrameGovernor({
|
|
880
|
+
targetLoadPercent: config.targetLoadPercent,
|
|
881
|
+
minThrottledFps: config.minThrottledFps,
|
|
882
|
+
maxConcurrentInferences: config.maxConcurrentInferences
|
|
883
|
+
});
|
|
823
884
|
}
|
|
824
885
|
/**
|
|
825
886
|
* Hot-reload the four tuning fields without tearing down the runner.
|
|
@@ -830,8 +891,15 @@ var PipelineRunner = class {
|
|
|
830
891
|
* (the FrameQueue implementation is latest-only and ignores maxSize
|
|
831
892
|
* anyway — see `frame-queue.ts` — so the field is effectively a
|
|
832
893
|
* metadata hint for observability).
|
|
833
|
-
* - `targetLoadPercent` / `minThrottledFps`: stored
|
|
834
|
-
*
|
|
894
|
+
* - `targetLoadPercent` / `minThrottledFps`: stored on `this.config` (read
|
|
895
|
+
* by `getLimits()`), but NOT re-fed into the live `FrameGovernor` — the
|
|
896
|
+
* governor is built once, at construction, from the config snapshot
|
|
897
|
+
* passed to the constructor. A hot patch to either knob changes what
|
|
898
|
+
* `getLimits()` reports but does not change live admission pacing until
|
|
899
|
+
* the runner is reconstructed. (Rebuilding the governor live is a
|
|
900
|
+
* follow-up, not in scope here — `maxConcurrentInferences` has the same
|
|
901
|
+
* gap: the semaphore resizes live but the governor's budget calc keeps
|
|
902
|
+
* the construction-time value.)
|
|
835
903
|
*
|
|
836
904
|
* Only keys present in the patch are overwritten; unspecified keys
|
|
837
905
|
* retain their current value. Any illegal combination (e.g.
|
|
@@ -877,13 +945,16 @@ var PipelineRunner = class {
|
|
|
877
945
|
lastArmedSource: null,
|
|
878
946
|
lastArmedRegions: void 0,
|
|
879
947
|
occupancyTimer: null,
|
|
880
|
-
motionInFlight: false
|
|
948
|
+
motionInFlight: false,
|
|
949
|
+
lastResultDetections: 0,
|
|
950
|
+
occupancyArmed: false,
|
|
951
|
+
emptyBurstStreak: 0
|
|
881
952
|
};
|
|
882
953
|
this.cameras.set(deviceId, state);
|
|
883
954
|
this.timingSampler.setDroppedFramesSource(deviceId, () => detectionQueue.droppedFrames);
|
|
884
955
|
if (registration.detectionMode === "on-motion") this.defaultRoundRobinKeys.push(deviceId);
|
|
885
956
|
if (registration.detectionMode === "on-motion" && registration.occupancyRecheckEnabled === true && (registration.occupancyRecheckSec ?? 0) > 0) state.occupancyTimer = setInterval(() => {
|
|
886
|
-
if (state.phase === "watching") this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
|
|
957
|
+
if (state.phase === "watching" && state.occupancyArmed) this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
|
|
887
958
|
}, (registration.occupancyRecheckSec ?? 0) * 1e3);
|
|
888
959
|
if (initialPhase === "active") {
|
|
889
960
|
this.detectionStreamHandler?.(deviceId, "subscribe");
|
|
@@ -912,6 +983,8 @@ var PipelineRunner = class {
|
|
|
912
983
|
state.detectionQueue.clear();
|
|
913
984
|
this.timingSampler.clearDroppedFramesSource(deviceId);
|
|
914
985
|
this.cameras.delete(deviceId);
|
|
986
|
+
this.lastAdmittedAt.delete(deviceId);
|
|
987
|
+
this.arrivalCounts.delete(deviceId);
|
|
915
988
|
const idx = this.defaultRoundRobinKeys.indexOf(deviceId);
|
|
916
989
|
if (idx !== -1) {
|
|
917
990
|
this.defaultRoundRobinKeys.splice(idx, 1);
|
|
@@ -929,13 +1002,29 @@ var PipelineRunner = class {
|
|
|
929
1002
|
enqueueDetectionFrame(deviceId, frame, handle) {
|
|
930
1003
|
const state = this.cameras.get(deviceId);
|
|
931
1004
|
if (!state) return;
|
|
1005
|
+
this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
|
|
932
1006
|
if (state.phase !== "active") return;
|
|
1007
|
+
const interval = this.governor.minIntervalMs(deviceId);
|
|
1008
|
+
if (interval > 0) {
|
|
1009
|
+
const last = this.lastAdmittedAt.get(deviceId) ?? 0;
|
|
1010
|
+
const now = Date.now();
|
|
1011
|
+
if (now - last < interval) return;
|
|
1012
|
+
this.lastAdmittedAt.set(deviceId, now);
|
|
1013
|
+
}
|
|
933
1014
|
frame._enqueuedAt = Date.now();
|
|
934
1015
|
state.detectionQueue.enqueue({
|
|
935
1016
|
frame,
|
|
936
1017
|
handle
|
|
937
1018
|
});
|
|
938
1019
|
}
|
|
1020
|
+
/**
|
|
1021
|
+
* `handle` is optional (mirrors {@link DetectionQueueEntry.handle}) — the
|
|
1022
|
+
* dial path (`runOccupancyBurst`'s `startFrameHandlePoller`) always has a
|
|
1023
|
+
* real shm handle, but Task 10's analyzer-tap path can feed a frame whose
|
|
1024
|
+
* motion subscription never produced one (e.g. the per-session motion
|
|
1025
|
+
* decode worker). `runInference` already falls back to the pixel path
|
|
1026
|
+
* when the handle is absent.
|
|
1027
|
+
*/
|
|
939
1028
|
enqueueOccupancyFrame(deviceId, frame, handle) {
|
|
940
1029
|
const state = this.cameras.get(deviceId);
|
|
941
1030
|
if (!state) return;
|
|
@@ -988,6 +1077,42 @@ var PipelineRunner = class {
|
|
|
988
1077
|
getPhase(deviceId) {
|
|
989
1078
|
return this.cameras.get(deviceId)?.phase;
|
|
990
1079
|
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Record the detection count from the runner's most recent inference
|
|
1082
|
+
* result for this camera. The addon wrapping the scheduler calls this
|
|
1083
|
+
* from its `onResult` callback — the runner itself stays event-agnostic
|
|
1084
|
+
* (it never inspects `FrameResult` shapes), it only remembers the raw
|
|
1085
|
+
* count that `transitionToWatching` reads to decide whether the
|
|
1086
|
+
* occupancy recheck timer arms for the upcoming `watching` phase.
|
|
1087
|
+
*/
|
|
1088
|
+
noteDetectionResult(deviceId, detectionCount) {
|
|
1089
|
+
const state = this.cameras.get(deviceId);
|
|
1090
|
+
if (!state) return;
|
|
1091
|
+
state.lastResultDetections = detectionCount;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Report the outcome of a completed occupancy recheck burst (see
|
|
1095
|
+
* `RunnerConfig.onOccupancyRecheck`). Called by the addon once it knows
|
|
1096
|
+
* whether any burst frame's inference result had detections.
|
|
1097
|
+
*
|
|
1098
|
+
* - `found: true` — resets the empty-burst streak. The timer stays armed;
|
|
1099
|
+
* a genuine occupant is a separate `reportMotion` event that reopens the
|
|
1100
|
+
* session through the existing motion/dispatch path — this method does
|
|
1101
|
+
* not itself flip the phase.
|
|
1102
|
+
* - `found: false` — increments the streak; after 2 consecutive empty
|
|
1103
|
+
* bursts the timer disarms (`occupancyArmed = false`) until the next
|
|
1104
|
+
* active→watching transition re-evaluates from scratch.
|
|
1105
|
+
*/
|
|
1106
|
+
reportOccupancyBurstOutcome(deviceId, found) {
|
|
1107
|
+
const state = this.cameras.get(deviceId);
|
|
1108
|
+
if (!state) return;
|
|
1109
|
+
if (found) {
|
|
1110
|
+
state.emptyBurstStreak = 0;
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
state.emptyBurstStreak++;
|
|
1114
|
+
if (state.emptyBurstStreak >= 2) state.occupancyArmed = false;
|
|
1115
|
+
}
|
|
991
1116
|
onResult(callback) {
|
|
992
1117
|
this.resultCallbacks.push(callback);
|
|
993
1118
|
}
|
|
@@ -995,12 +1120,19 @@ var PipelineRunner = class {
|
|
|
995
1120
|
if (this.intervalHandle !== null) return;
|
|
996
1121
|
this.intervalHandle = setInterval(() => this.tick(), 10);
|
|
997
1122
|
this.timingSampler.start();
|
|
1123
|
+
this.lastGovernorRecomputeAt = Date.now();
|
|
1124
|
+
this.governorIntervalHandle = setInterval(() => this.recomputeGovernor(), GOVERNOR_RECOMPUTE_INTERVAL_MS);
|
|
1125
|
+
this.governorIntervalHandle?.unref?.();
|
|
998
1126
|
}
|
|
999
1127
|
stop() {
|
|
1000
1128
|
if (this.intervalHandle !== null) {
|
|
1001
1129
|
clearInterval(this.intervalHandle);
|
|
1002
1130
|
this.intervalHandle = null;
|
|
1003
1131
|
}
|
|
1132
|
+
if (this.governorIntervalHandle !== null) {
|
|
1133
|
+
clearInterval(this.governorIntervalHandle);
|
|
1134
|
+
this.governorIntervalHandle = null;
|
|
1135
|
+
}
|
|
1004
1136
|
this.timingSampler.stop();
|
|
1005
1137
|
for (const state of this.cameras.values()) {
|
|
1006
1138
|
if (state.motionCooldownTimer !== null) {
|
|
@@ -1013,6 +1145,31 @@ var PipelineRunner = class {
|
|
|
1013
1145
|
}
|
|
1014
1146
|
}
|
|
1015
1147
|
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Recompute the {@link FrameGovernor}'s per-camera admission intervals
|
|
1150
|
+
* from fresh sampler + arrival data. Called on the {@link
|
|
1151
|
+
* GOVERNOR_RECOMPUTE_INTERVAL_MS} tick started by `start()`; also exposed
|
|
1152
|
+
* so tests can prime the governor deterministically without waiting on a
|
|
1153
|
+
* timer. `now` defaults to the wall clock but is accepted explicitly so a
|
|
1154
|
+
* caller can pass a fixed instant for reproducible elapsed-time math.
|
|
1155
|
+
*/
|
|
1156
|
+
recomputeGovernor(now = Date.now()) {
|
|
1157
|
+
const elapsedSec = Math.max((now - this.lastGovernorRecomputeAt) / 1e3, .001);
|
|
1158
|
+
const avgInferenceMsByCamera = this.timingSampler.avgInferenceMsByCamera();
|
|
1159
|
+
const samples = /* @__PURE__ */ new Map();
|
|
1160
|
+
for (const [deviceId, state] of this.cameras) {
|
|
1161
|
+
if (state.phase !== "active") continue;
|
|
1162
|
+
const measuredDecodeFps = (this.arrivalCounts.get(deviceId) ?? 0) / elapsedSec;
|
|
1163
|
+
samples.set(deviceId, {
|
|
1164
|
+
avgInferenceMs: avgInferenceMsByCamera.get(deviceId) ?? 0,
|
|
1165
|
+
configuredFps: state.registration.fps,
|
|
1166
|
+
decodeFps: measuredDecodeFps > 0 ? measuredDecodeFps : DEFAULT_DECODE_FPS
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
this.governor.update(samples);
|
|
1170
|
+
this.arrivalCounts.clear();
|
|
1171
|
+
this.lastGovernorRecomputeAt = now;
|
|
1172
|
+
}
|
|
1016
1173
|
getMetrics() {
|
|
1017
1174
|
let totalQueueDepth = 0;
|
|
1018
1175
|
let totalInferenceTime = 0;
|
|
@@ -1026,7 +1183,7 @@ var PipelineRunner = class {
|
|
|
1026
1183
|
}
|
|
1027
1184
|
return {
|
|
1028
1185
|
activeCameras: this.cameras.size,
|
|
1029
|
-
throttledCameras:
|
|
1186
|
+
throttledCameras: this.governor.throttledCount(),
|
|
1030
1187
|
avgInferenceTimeMs: totalInferenceCount > 0 ? totalInferenceTime / totalInferenceCount : 0,
|
|
1031
1188
|
queueDepth: totalQueueDepth
|
|
1032
1189
|
};
|
|
@@ -1064,6 +1221,7 @@ var PipelineRunner = class {
|
|
|
1064
1221
|
}
|
|
1065
1222
|
transitionToActive(deviceId, state, source, regions, cooldownMs) {
|
|
1066
1223
|
state.phase = "active";
|
|
1224
|
+
state.lastResultDetections = 0;
|
|
1067
1225
|
this.logger?.info("motion gate opened — phase=active", {
|
|
1068
1226
|
tags: { deviceId },
|
|
1069
1227
|
meta: {
|
|
@@ -1082,9 +1240,14 @@ var PipelineRunner = class {
|
|
|
1082
1240
|
transitionToWatching(deviceId, state, cooldownMs) {
|
|
1083
1241
|
state.phase = "watching";
|
|
1084
1242
|
state.detectionQueue.clear();
|
|
1243
|
+
state.occupancyArmed = state.lastResultDetections > 0;
|
|
1244
|
+
state.emptyBurstStreak = 0;
|
|
1085
1245
|
this.logger?.info("motion gate closed — phase=watching", {
|
|
1086
1246
|
tags: { deviceId },
|
|
1087
|
-
meta: {
|
|
1247
|
+
meta: {
|
|
1248
|
+
lastSource: state.lastArmedSource,
|
|
1249
|
+
occupancyArmed: state.occupancyArmed
|
|
1250
|
+
}
|
|
1088
1251
|
});
|
|
1089
1252
|
this.detectionStreamHandler?.(deviceId, "unsubscribe");
|
|
1090
1253
|
const source = state.lastArmedSource ?? "analyzer";
|
|
@@ -1221,9 +1384,11 @@ var KILL_SIGNALS = {
|
|
|
1221
1384
|
};
|
|
1222
1385
|
/** Adapt a Node {@link ChildProcess} to the coordinator's {@link ForkedChildLike}. */
|
|
1223
1386
|
function wrapChild(child) {
|
|
1387
|
+
child.on("error", () => void 0);
|
|
1224
1388
|
return {
|
|
1225
1389
|
send: (msg) => {
|
|
1226
|
-
child.
|
|
1390
|
+
if (!child.connected) return;
|
|
1391
|
+
child.send(msg, () => void 0);
|
|
1227
1392
|
},
|
|
1228
1393
|
on: (ev, cb) => {
|
|
1229
1394
|
if (ev === "exit") child.on("exit", () => cb(void 0));
|
|
@@ -1542,6 +1707,18 @@ var SessionDecodeSession = class {
|
|
|
1542
1707
|
}
|
|
1543
1708
|
});
|
|
1544
1709
|
}
|
|
1710
|
+
/**
|
|
1711
|
+
* Prompt-release the worker's RAM native-frame lease for `frameId`. Fire and
|
|
1712
|
+
* forget (no reply): a lease also self-expires on its TTL, so a dropped
|
|
1713
|
+
* release only defers reclaim. No-op once the worker has exited.
|
|
1714
|
+
*/
|
|
1715
|
+
releaseLease(frameId) {
|
|
1716
|
+
if (this.exited) return;
|
|
1717
|
+
this.trySend({
|
|
1718
|
+
kind: "releaseNativeLease",
|
|
1719
|
+
frameId
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1545
1722
|
/** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
|
|
1546
1723
|
teardown() {
|
|
1547
1724
|
this.clearPullTimeout();
|
|
@@ -1749,6 +1926,7 @@ function buildFrameImage(session, reply) {
|
|
|
1749
1926
|
return session.requestBuffer(reply.frameId, opts);
|
|
1750
1927
|
},
|
|
1751
1928
|
nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
|
|
1929
|
+
releaseNativeLease: () => session.releaseLease(reply.frameId),
|
|
1752
1930
|
close: () => {
|
|
1753
1931
|
closed = true;
|
|
1754
1932
|
}
|
|
@@ -1890,7 +2068,8 @@ function startSessionDecodePump(deps) {
|
|
|
1890
2068
|
try {
|
|
1891
2069
|
const decoded = await toDecodedFrame(frame, format);
|
|
1892
2070
|
const image = frame.image;
|
|
1893
|
-
const
|
|
2071
|
+
const cropFn = image.nativeCrop;
|
|
2072
|
+
const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), { release: () => image.releaseNativeLease?.() }) : void 0;
|
|
1894
2073
|
deps.onDecodedFrame(decoded, nativeCrop);
|
|
1895
2074
|
} catch (err) {
|
|
1896
2075
|
deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
|
|
@@ -1924,6 +2103,12 @@ function startSessionDecodePump(deps) {
|
|
|
1924
2103
|
iterator.return().catch(() => {});
|
|
1925
2104
|
};
|
|
1926
2105
|
}
|
|
2106
|
+
/** Invoke a requester's best-effort `release()`, swallowing any transport error. */
|
|
2107
|
+
function safeRelease(request) {
|
|
2108
|
+
try {
|
|
2109
|
+
request.release();
|
|
2110
|
+
} catch {}
|
|
2111
|
+
}
|
|
1927
2112
|
var NativeCropRegistry = class {
|
|
1928
2113
|
capacity;
|
|
1929
2114
|
entries = /* @__PURE__ */ new Map();
|
|
@@ -1938,7 +2123,10 @@ var NativeCropRegistry = class {
|
|
|
1938
2123
|
key(handle) {
|
|
1939
2124
|
return `${handle.shmId}#${handle.slot}#${handle.seq}`;
|
|
1940
2125
|
}
|
|
1941
|
-
/**
|
|
2126
|
+
/**
|
|
2127
|
+
* Bind `request` to `handle`; evicts the oldest entry past the cap, releasing
|
|
2128
|
+
* its worker-side RAM lease so a lease never outlives its registry entry.
|
|
2129
|
+
*/
|
|
1942
2130
|
register(handle, request) {
|
|
1943
2131
|
const k = this.key(handle);
|
|
1944
2132
|
this.entries.delete(k);
|
|
@@ -1946,19 +2134,173 @@ var NativeCropRegistry = class {
|
|
|
1946
2134
|
while (this.entries.size > this.capacity) {
|
|
1947
2135
|
const oldest = this.entries.keys().next().value;
|
|
1948
2136
|
if (oldest === void 0) break;
|
|
1949
|
-
this.
|
|
2137
|
+
this.releaseEntry(oldest);
|
|
1950
2138
|
}
|
|
1951
2139
|
}
|
|
1952
2140
|
/** The requester for `handle`, or `null` if never registered / already evicted. */
|
|
1953
2141
|
get(handle) {
|
|
1954
2142
|
return this.entries.get(this.key(handle)) ?? null;
|
|
1955
2143
|
}
|
|
1956
|
-
/**
|
|
2144
|
+
/**
|
|
2145
|
+
* Release the worker-side RAM native lease for `handle` and drop its entry —
|
|
2146
|
+
* the caller is done cutting native crops from that frame. Best-effort +
|
|
2147
|
+
* idempotent (an unknown handle is a no-op; the lease also self-expires).
|
|
2148
|
+
*/
|
|
2149
|
+
release(handle) {
|
|
2150
|
+
this.releaseEntry(this.key(handle));
|
|
2151
|
+
}
|
|
2152
|
+
/** Drop every entry, releasing each frame's worker-side lease (node/addon shutdown). */
|
|
1957
2153
|
clear() {
|
|
2154
|
+
for (const request of this.entries.values()) safeRelease(request);
|
|
1958
2155
|
this.entries.clear();
|
|
1959
2156
|
}
|
|
2157
|
+
/** Release + drop one entry by its map key. */
|
|
2158
|
+
releaseEntry(key) {
|
|
2159
|
+
const request = this.entries.get(key);
|
|
2160
|
+
if (!request) return;
|
|
2161
|
+
this.entries.delete(key);
|
|
2162
|
+
safeRelease(request);
|
|
2163
|
+
}
|
|
1960
2164
|
};
|
|
1961
2165
|
//#endregion
|
|
2166
|
+
//#region src/pipeline-runner/detail-subtree.ts
|
|
2167
|
+
/** Relative padding applied to the parent bbox before cutting the crop — more context for the child model. */
|
|
2168
|
+
var DETAIL_CROP_PADDING_RATIO = .15;
|
|
2169
|
+
/**
|
|
2170
|
+
* Pad `bbox` by {@link DETAIL_CROP_PADDING_RATIO} of its own size, then clamp
|
|
2171
|
+
* to `[0, frameWidth] x [0, frameHeight]`. Pure — always returns a new rect.
|
|
2172
|
+
*/
|
|
2173
|
+
function padAndClampFrameBbox(bbox, frameWidth, frameHeight) {
|
|
2174
|
+
const padX = DETAIL_CROP_PADDING_RATIO * bbox.w;
|
|
2175
|
+
const padY = DETAIL_CROP_PADDING_RATIO * bbox.h;
|
|
2176
|
+
const x1 = Math.max(0, bbox.x - padX);
|
|
2177
|
+
const y1 = Math.max(0, bbox.y - padY);
|
|
2178
|
+
const x2 = Math.min(frameWidth, bbox.x + bbox.w + padX);
|
|
2179
|
+
const y2 = Math.min(frameHeight, bbox.y + bbox.h + padY);
|
|
2180
|
+
return {
|
|
2181
|
+
x: x1,
|
|
2182
|
+
y: y1,
|
|
2183
|
+
w: Math.max(0, x2 - x1),
|
|
2184
|
+
h: Math.max(0, y2 - y1)
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
/** Normalize a frame-space pixel rect to `[0,1]` by the given frame dims. */
|
|
2188
|
+
function normalizeRect(rect, frameWidth, frameHeight) {
|
|
2189
|
+
return {
|
|
2190
|
+
x: rect.x / frameWidth,
|
|
2191
|
+
y: rect.y / frameHeight,
|
|
2192
|
+
w: rect.w / frameWidth,
|
|
2193
|
+
h: rect.h / frameHeight
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
/** Inverse of {@link normalizeRect} — denormalize a `[0,1]` rect back to frame-space pixels. */
|
|
2197
|
+
function denormalizeRect(rect, frameWidth, frameHeight) {
|
|
2198
|
+
return {
|
|
2199
|
+
x: rect.x * frameWidth,
|
|
2200
|
+
y: rect.y * frameHeight,
|
|
2201
|
+
w: rect.w * frameWidth,
|
|
2202
|
+
h: rect.h * frameHeight
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Walk `steps` (the attach config's tree) collecting every ENABLED step
|
|
2207
|
+
* whose catalog definition's `inputClasses` includes `parentClassName` —
|
|
2208
|
+
* these become the subtree's roots. A matched step's own nested `children`
|
|
2209
|
+
* ride along UNCHANGED (transitively included: `face-detection` pulls
|
|
2210
|
+
* `face-embedding`, `plate-detection` pulls `plate-ocr`) — recursion stops
|
|
2211
|
+
* at a match rather than also searching inside it for further matches.
|
|
2212
|
+
* `filter`, when given, keeps only matches whose `addonId` is in the list.
|
|
2213
|
+
*/
|
|
2214
|
+
function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, filter) {
|
|
2215
|
+
const matches = [];
|
|
2216
|
+
const walk = (list) => {
|
|
2217
|
+
for (const step of list) {
|
|
2218
|
+
if (!step.enabled) continue;
|
|
2219
|
+
const definition = lookupStepDefinition(step.addonId);
|
|
2220
|
+
if (definition !== null && definition.inputClasses !== null && definition.inputClasses.includes(parentClassName)) {
|
|
2221
|
+
if (!filter || filter.includes(step.addonId)) matches.push(step);
|
|
2222
|
+
continue;
|
|
2223
|
+
}
|
|
2224
|
+
if (step.children) walk(step.children);
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
2227
|
+
walk(steps);
|
|
2228
|
+
return matches;
|
|
2229
|
+
}
|
|
2230
|
+
/** Map a detection's CROP-pixel bbox back to FRAME-space pixels. */
|
|
2231
|
+
function mapBboxToFrameSpace(bbox, crop) {
|
|
2232
|
+
const scaleX = crop.frameSpace.w / crop.width;
|
|
2233
|
+
const scaleY = crop.frameSpace.h / crop.height;
|
|
2234
|
+
return {
|
|
2235
|
+
x: crop.frameSpace.x + bbox.x * scaleX,
|
|
2236
|
+
y: crop.frameSpace.y + bbox.y * scaleY,
|
|
2237
|
+
w: bbox.width * scaleX,
|
|
2238
|
+
h: bbox.height * scaleY
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
/** Base64-encode a normalized float32 embedding vector. */
|
|
2242
|
+
function encodeEmbeddingBase64(embedding) {
|
|
2243
|
+
return Buffer.from(Float32Array.from(embedding).buffer).toString("base64");
|
|
2244
|
+
}
|
|
2245
|
+
/** First user-visible label — skips the opaque `embedding:*` marker (see `result-assembler.ts`). */
|
|
2246
|
+
function pickLabel(detection) {
|
|
2247
|
+
return detection.labels.find((l) => !l.label.startsWith("embedding:"))?.label;
|
|
2248
|
+
}
|
|
2249
|
+
function toDetailResult(detection, stepId, crop) {
|
|
2250
|
+
const label = pickLabel(detection);
|
|
2251
|
+
return {
|
|
2252
|
+
stepId,
|
|
2253
|
+
className: detection.macroClass,
|
|
2254
|
+
score: detection.score,
|
|
2255
|
+
bbox: mapBboxToFrameSpace(detection.bbox, crop),
|
|
2256
|
+
...detection.embedding !== void 0 ? { embedding: encodeEmbeddingBase64(detection.embedding) } : {},
|
|
2257
|
+
...label !== void 0 ? { label } : {},
|
|
2258
|
+
...detection.faceAlignedCrop !== void 0 ? { alignedCropJpeg: detection.faceAlignedCrop } : {}
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
async function resolveCrop(deps, input) {
|
|
2262
|
+
if (input.frameHandle) {
|
|
2263
|
+
const normalized = normalizeRect(padAndClampFrameBbox(input.parent.bbox, input.frameHandle.width, input.frameHandle.height), input.frameHandle.width, input.frameHandle.height);
|
|
2264
|
+
const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
|
|
2265
|
+
if (native) return native;
|
|
2266
|
+
}
|
|
2267
|
+
if (input.cropJpeg) {
|
|
2268
|
+
const jpeg = Buffer.from(input.cropJpeg, "base64");
|
|
2269
|
+
const sharp = (await import("sharp")).default;
|
|
2270
|
+
const meta = await sharp(jpeg).metadata();
|
|
2271
|
+
const width = meta.width ?? 0;
|
|
2272
|
+
const height = meta.height ?? 0;
|
|
2273
|
+
if (width <= 0 || height <= 0) return null;
|
|
2274
|
+
return {
|
|
2275
|
+
jpeg,
|
|
2276
|
+
width,
|
|
2277
|
+
height,
|
|
2278
|
+
frameSpace: input.parent.bbox
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
return null;
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Run the DETAIL subtree for a single tracked detection: resolve a crop
|
|
2285
|
+
* (native lease → `cropJpeg` fallback), run each matched top-level child
|
|
2286
|
+
* step's chain against it, and map every result bbox back to frame space.
|
|
2287
|
+
* Returns `null` when the camera isn't attached, no configured child
|
|
2288
|
+
* matches `input.parent.className`, or no crop source resolves.
|
|
2289
|
+
*/
|
|
2290
|
+
async function runDetailSubtree(deps, input) {
|
|
2291
|
+
const steps = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
|
|
2292
|
+
if (!steps || steps.length === 0) return null;
|
|
2293
|
+
const crop = await resolveCrop(deps, input);
|
|
2294
|
+
if (!crop) return null;
|
|
2295
|
+
const details = [];
|
|
2296
|
+
for (const step of steps) {
|
|
2297
|
+
const result = await deps.runPipeline([step], crop.jpeg, input.deviceId);
|
|
2298
|
+
if (!result) continue;
|
|
2299
|
+
for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
|
|
2300
|
+
}
|
|
2301
|
+
return { details };
|
|
2302
|
+
}
|
|
2303
|
+
//#endregion
|
|
1962
2304
|
//#region src/pipeline-runner/index.ts
|
|
1963
2305
|
var DEFAULT_CONFIG = {
|
|
1964
2306
|
maxQueueDepth: 30,
|
|
@@ -1983,6 +2325,23 @@ var DEFAULT_CONFIG = {
|
|
|
1983
2325
|
*/
|
|
1984
2326
|
var DETECTION_KEEPWARM_MS = 15e3;
|
|
1985
2327
|
/**
|
|
2328
|
+
* Occupancy-burst safety timeout (both the dial path and the analyzer-tap
|
|
2329
|
+
* path — Task 10). Bounds worst-case burst latency when frames stop
|
|
2330
|
+
* arriving mid-burst — a dial that never gets a keyframe, or a tap whose
|
|
2331
|
+
* motion subscription closes before N frames arrive.
|
|
2332
|
+
*/
|
|
2333
|
+
var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
|
|
2334
|
+
/**
|
|
2335
|
+
* Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
|
|
2336
|
+
* `attachment.motionUnsubscribe` only proves a subscription handle exists —
|
|
2337
|
+
* NOT that frames are actually flowing (guarded-session-decode can return
|
|
2338
|
+
* its teardown synchronously, and a failed `acquireSessionDecodeRestreamFrom`
|
|
2339
|
+
* acquire never retries, leaving the handle permanently non-null with zero
|
|
2340
|
+
* frames arriving). `runOccupancyBurst` additionally requires a motion frame
|
|
2341
|
+
* to have been teed within this window before it will route to the tap path.
|
|
2342
|
+
*/
|
|
2343
|
+
var MOTION_FRESHNESS_WINDOW_MS = 5e3;
|
|
2344
|
+
/**
|
|
1986
2345
|
* Pure decision helper: returns `true` when a dynamic frame-diff analyzer
|
|
1987
2346
|
* subscription should be opened for an onboard-motion camera.
|
|
1988
2347
|
*
|
|
@@ -1999,6 +2358,62 @@ function shouldStartOnboardAnalyzer(config) {
|
|
|
1999
2358
|
if (config.motionSources.includes("analyzer")) return false;
|
|
2000
2359
|
return true;
|
|
2001
2360
|
}
|
|
2361
|
+
/** Recursively collects the `addonId` of every ENABLED step in the tree. */
|
|
2362
|
+
function collectEnabledStepIds(steps, acc) {
|
|
2363
|
+
if (!steps) return;
|
|
2364
|
+
for (const step of steps) {
|
|
2365
|
+
if (!step.enabled) continue;
|
|
2366
|
+
acc.add(step.addonId);
|
|
2367
|
+
collectEnabledStepIds(step.children, acc);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Looks up a catalog step definition, tolerating an unknown id. A stale
|
|
2372
|
+
* config entry (removed/custom addon) must degrade to "not announced"
|
|
2373
|
+
* rather than fail the attach — `getStepDefinition` throws on a miss.
|
|
2374
|
+
*/
|
|
2375
|
+
function tryGetStepDefinition(stepId) {
|
|
2376
|
+
try {
|
|
2377
|
+
return getStepDefinition(stepId);
|
|
2378
|
+
} catch {
|
|
2379
|
+
return null;
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Precomputes the `detailSteps` announce (two-plane design) for a camera's
|
|
2384
|
+
* configured step tree: every ENABLED step whose catalog definition is a
|
|
2385
|
+
* detail/child step (`inputClasses !== null` — a root step like
|
|
2386
|
+
* `object-detection` runs on every frame on the `'frame'` plane and has
|
|
2387
|
+
* nothing to announce) is surfaced as `{ stepId, inputClasses, cadence }`
|
|
2388
|
+
* so a track-level consumer knows what it can dispatch via
|
|
2389
|
+
* `pipelineRunner.runDetailSubtree` and on what cadence, without a
|
|
2390
|
+
* separate round-trip to the executor's schema/config caps.
|
|
2391
|
+
*
|
|
2392
|
+
* Unknown step ids (stale config referencing a removed/custom addon) are
|
|
2393
|
+
* skipped rather than thrown — the catalog is the single source of truth
|
|
2394
|
+
* for cadence and a stale entry must not fail the attach.
|
|
2395
|
+
*
|
|
2396
|
+
* Extracted as a pure function so the unit suite can exercise the
|
|
2397
|
+
* derivation without instantiating the full addon.
|
|
2398
|
+
*/
|
|
2399
|
+
function deriveDetailSteps(steps) {
|
|
2400
|
+
const stepIds = /* @__PURE__ */ new Set();
|
|
2401
|
+
collectEnabledStepIds(steps, stepIds);
|
|
2402
|
+
const announces = [];
|
|
2403
|
+
for (const stepId of stepIds) {
|
|
2404
|
+
const def = tryGetStepDefinition(stepId);
|
|
2405
|
+
if (!def || def.inputClasses === null) continue;
|
|
2406
|
+
announces.push({
|
|
2407
|
+
stepId: def.id,
|
|
2408
|
+
inputClasses: def.inputClasses,
|
|
2409
|
+
cadence: def.cadence ?? {
|
|
2410
|
+
trigger: "once",
|
|
2411
|
+
maxPerTrack: 3
|
|
2412
|
+
}
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
return announces;
|
|
2416
|
+
}
|
|
2002
2417
|
function toFrameInput(frame) {
|
|
2003
2418
|
return {
|
|
2004
2419
|
data: frame.data,
|
|
@@ -2260,6 +2675,13 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
2260
2675
|
/** Per-camera single-flight: a camera never runs two occupancy bursts at once. */
|
|
2261
2676
|
occupancyBurstInFlight = /* @__PURE__ */ new Set();
|
|
2262
2677
|
/**
|
|
2678
|
+
* Devices whose in-flight occupancy burst has seen at least one inference
|
|
2679
|
+
* result with detections>0 (set from the `onResult` handler above, cleared
|
|
2680
|
+
* per-burst in `runOccupancyBurst`). Read at burst cleanup and reported to
|
|
2681
|
+
* `PipelineRunner.reportOccupancyBurstOutcome` — Task 9's arm/disarm signal.
|
|
2682
|
+
*/
|
|
2683
|
+
occupancyBurstFound = /* @__PURE__ */ new Set();
|
|
2684
|
+
/**
|
|
2263
2685
|
* GLOBAL occupancy admission queue. Each occupancy burst SUBSCRIBES a detection
|
|
2264
2686
|
* stream (rgb) which, during the `watching` phase, has no existing consumer and
|
|
2265
2687
|
* therefore makes the broker DIAL a fresh decoder. Without a global cap, N
|
|
@@ -2276,6 +2698,23 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
2276
2698
|
occupancyActiveCount = 0;
|
|
2277
2699
|
/** Max concurrent occupancy bursts (decoder dials) across all cameras. */
|
|
2278
2700
|
occupancyMaxConcurrent = 3;
|
|
2701
|
+
/**
|
|
2702
|
+
* TAP-based occupancy bursts in flight, keyed by deviceId (Task 10). See
|
|
2703
|
+
* {@link OccupancyTapState}. A device has an entry here XOR is dialing via
|
|
2704
|
+
* `startFrameHandlePoller` in `runOccupancyBurst` — never both; the choice
|
|
2705
|
+
* is made once at burst start based on `attachment.motionUnsubscribe`.
|
|
2706
|
+
*/
|
|
2707
|
+
occupancyTaps = /* @__PURE__ */ new Map();
|
|
2708
|
+
/**
|
|
2709
|
+
* Timestamp (`Date.now()`) of the last motion frame teed for each device
|
|
2710
|
+
* — set in `runMotionAnalysis` at the same call site as
|
|
2711
|
+
* `teeOccupancyTapFrame` (Task 10 hardening). This is the TRUTHFUL "is
|
|
2712
|
+
* the analyzer subscription actually delivering frames" signal;
|
|
2713
|
+
* `attachment.motionUnsubscribe !== null` alone only proves a
|
|
2714
|
+
* subscription handle was assigned, not that it's live. See
|
|
2715
|
+
* {@link MOTION_FRESHNESS_WINDOW_MS}.
|
|
2716
|
+
*/
|
|
2717
|
+
lastMotionFrameAt = /* @__PURE__ */ new Map();
|
|
2279
2718
|
constructor() {
|
|
2280
2719
|
super({ ...DEFAULT_CONFIG });
|
|
2281
2720
|
}
|
|
@@ -2312,6 +2751,9 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
2312
2751
|
});
|
|
2313
2752
|
this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
|
|
2314
2753
|
this.emitInferenceResult(deviceId, frame, result, handle);
|
|
2754
|
+
const detectionCount = result.detections?.length ?? 0;
|
|
2755
|
+
this.runner?.noteDetectionResult(deviceId, detectionCount);
|
|
2756
|
+
if (detectionCount > 0 && this.occupancyBurstInFlight.has(deviceId)) this.occupancyBurstFound.add(deviceId);
|
|
2315
2757
|
});
|
|
2316
2758
|
this.runner.start();
|
|
2317
2759
|
this.ctx.logger.info("Pipeline runner started", {
|
|
@@ -2377,6 +2819,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
2377
2819
|
attachment.detectionUnsubscribe?.();
|
|
2378
2820
|
}
|
|
2379
2821
|
this.attached.clear();
|
|
2822
|
+
this.lastMotionFrameAt.clear();
|
|
2380
2823
|
}
|
|
2381
2824
|
async cacheBenchFrame(input) {
|
|
2382
2825
|
const sharp = (await import("sharp")).default;
|
|
@@ -2722,7 +3165,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
2722
3165
|
config,
|
|
2723
3166
|
motionUnsubscribe: null,
|
|
2724
3167
|
detectionUnsubscribe: null,
|
|
2725
|
-
detectionKeepWarmTimer: null
|
|
3168
|
+
detectionKeepWarmTimer: null,
|
|
3169
|
+
detailSteps: deriveDetailSteps(config.steps)
|
|
2726
3170
|
};
|
|
2727
3171
|
this.attached.set(config.deviceId, attachment);
|
|
2728
3172
|
runner.registerCamera(config.deviceId, {
|
|
@@ -2775,6 +3219,78 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
2775
3219
|
height: result.height
|
|
2776
3220
|
} : null;
|
|
2777
3221
|
}
|
|
3222
|
+
/**
|
|
3223
|
+
* Two-plane design: run the DETAIL subtree (crop children) for a single
|
|
3224
|
+
* tracked detection. Delegates the algorithm to the deps-injected pure
|
|
3225
|
+
* function in {@link runDetailSubtreeImpl} — this method only wires the
|
|
3226
|
+
* real deps: the attach config's step tree (+ the catalog, for the
|
|
3227
|
+
* `inputClasses` match), the SAME internal native-crop lease path
|
|
3228
|
+
* `getNativeCrop` uses above (no `ctx.api` round-trip — we're already in
|
|
3229
|
+
* the frame's owning process), and the node-local `pipelineExecutor`.
|
|
3230
|
+
*/
|
|
3231
|
+
async runDetailSubtree(input) {
|
|
3232
|
+
return runDetailSubtree({
|
|
3233
|
+
resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
|
|
3234
|
+
getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
|
|
3235
|
+
runPipeline: (steps, imageJpeg, deviceId) => this.runDetailPipeline(steps, imageJpeg, deviceId)
|
|
3236
|
+
}, input);
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Real `resolveChildSteps` dep: `null` when the camera isn't attached
|
|
3240
|
+
* (matches the cap doc — "camera not attached" is a distinct case from
|
|
3241
|
+
* "attached but no matching child steps", which returns an empty array
|
|
3242
|
+
* and short-circuits inside {@link runDetailSubtreeImpl} the same way).
|
|
3243
|
+
*/
|
|
3244
|
+
resolveDetailChildSteps(deviceId, parentClassName, filter) {
|
|
3245
|
+
const attachment = this.attached.get(deviceId);
|
|
3246
|
+
if (!attachment) return null;
|
|
3247
|
+
const steps = attachment.config.steps;
|
|
3248
|
+
if (!steps) return [];
|
|
3249
|
+
return findMatchingDetailSteps(steps, parentClassName, tryGetStepDefinition, filter);
|
|
3250
|
+
}
|
|
3251
|
+
/**
|
|
3252
|
+
* Real `getNativeCrop` dep: reuses the node-local {@link nativeCropRegistry}
|
|
3253
|
+
* lease (same path the `getNativeCrop` cap method above uses), encodes the
|
|
3254
|
+
* raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
|
|
3255
|
+
* pixels for the `frameSpace` the caller maps result bboxes against.
|
|
3256
|
+
*/
|
|
3257
|
+
async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
|
|
3258
|
+
const native = await this.getNativeCrop({
|
|
3259
|
+
handle,
|
|
3260
|
+
bbox: paddedBbox
|
|
3261
|
+
});
|
|
3262
|
+
if (!native) return null;
|
|
3263
|
+
const sharp = (await import("sharp")).default;
|
|
3264
|
+
return {
|
|
3265
|
+
jpeg: await sharp(Buffer.from(native.bytes), { raw: {
|
|
3266
|
+
width: native.width,
|
|
3267
|
+
height: native.height,
|
|
3268
|
+
channels: 3
|
|
3269
|
+
} }).jpeg({ quality: 90 }).toBuffer(),
|
|
3270
|
+
width: native.width,
|
|
3271
|
+
height: native.height,
|
|
3272
|
+
frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
/** Real `runPipeline` dep: node-local `pipelineExecutor.runPipeline` on the `'full'` plane. */
|
|
3276
|
+
async runDetailPipeline(steps, imageJpeg, deviceId) {
|
|
3277
|
+
const api = this.ctxIfReady?.api;
|
|
3278
|
+
if (!api) return null;
|
|
3279
|
+
try {
|
|
3280
|
+
return await api.pipelineExecutor.runPipeline.mutate({
|
|
3281
|
+
steps: [...steps],
|
|
3282
|
+
image: new Uint8Array(imageJpeg),
|
|
3283
|
+
deviceId,
|
|
3284
|
+
plane: "full"
|
|
3285
|
+
});
|
|
3286
|
+
} catch (err) {
|
|
3287
|
+
this.ctxIfReady?.logger.warn("runDetailSubtree: runPipeline failed", {
|
|
3288
|
+
tags: { deviceId },
|
|
3289
|
+
meta: { error: errMsg(err) }
|
|
3290
|
+
});
|
|
3291
|
+
return null;
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
2778
3294
|
detachInternal(deviceId) {
|
|
2779
3295
|
const attachment = this.attached.get(deviceId);
|
|
2780
3296
|
if (!attachment) return;
|
|
@@ -2785,6 +3301,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
2785
3301
|
}
|
|
2786
3302
|
attachment.motionUnsubscribe?.();
|
|
2787
3303
|
attachment.detectionUnsubscribe?.();
|
|
3304
|
+
this.lastMotionFrameAt.delete(deviceId);
|
|
2788
3305
|
const remotePlane = this.remotePlanes.get(deviceId);
|
|
2789
3306
|
if (remotePlane) {
|
|
2790
3307
|
this.remotePlanes.delete(deviceId);
|
|
@@ -3317,7 +3834,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
3317
3834
|
return await api.pipelineExecutor.runPipeline.mutate({
|
|
3318
3835
|
steps: [...steps],
|
|
3319
3836
|
...useHandle ? { frameHandle: handle } : { frame },
|
|
3320
|
-
deviceId
|
|
3837
|
+
deviceId,
|
|
3838
|
+
plane: "frame"
|
|
3321
3839
|
});
|
|
3322
3840
|
} catch (err) {
|
|
3323
3841
|
const msg = errMsg(err);
|
|
@@ -3329,6 +3847,8 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
3329
3847
|
const ctx = this.ctx;
|
|
3330
3848
|
const runner = this.runner;
|
|
3331
3849
|
if (!ctx || !runner) return;
|
|
3850
|
+
this.lastMotionFrameAt.set(deviceId, Date.now());
|
|
3851
|
+
this.teeOccupancyTapFrame(deviceId, frame, handle);
|
|
3332
3852
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
3333
3853
|
const motionStart = Date.now();
|
|
3334
3854
|
try {
|
|
@@ -3443,11 +3963,98 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
3443
3963
|
});
|
|
3444
3964
|
}
|
|
3445
3965
|
}
|
|
3966
|
+
/**
|
|
3967
|
+
* Shared burst-completion path (Task 9 report + Task 10 dial/tap
|
|
3968
|
+
* consolidation): clears the in-flight flag, reads the `found` flag the
|
|
3969
|
+
* `onResult` handler set while the burst was live, reports it to the
|
|
3970
|
+
* runner's arm/disarm state machine, and logs. Both the dial path
|
|
3971
|
+
* (`runOccupancyBurst`'s `cleanup`) and the tap path
|
|
3972
|
+
* (`finishOccupancyTap`) fold into this single implementation so the
|
|
3973
|
+
* found/report semantics can't drift between the two admission
|
|
3974
|
+
* mechanisms.
|
|
3975
|
+
*/
|
|
3976
|
+
finishOccupancyBurst(deviceId, log, collected) {
|
|
3977
|
+
this.occupancyBurstInFlight.delete(deviceId);
|
|
3978
|
+
const found = this.occupancyBurstFound.has(deviceId);
|
|
3979
|
+
this.occupancyBurstFound.delete(deviceId);
|
|
3980
|
+
this.runner?.reportOccupancyBurstOutcome(deviceId, found);
|
|
3981
|
+
log.debug("occupancy re-check: burst complete", { meta: {
|
|
3982
|
+
collected,
|
|
3983
|
+
found
|
|
3984
|
+
} });
|
|
3985
|
+
}
|
|
3986
|
+
/**
|
|
3987
|
+
* Task 10: arm a TAP-based occupancy burst for a device whose analyzer
|
|
3988
|
+
* motion subscription is already flowing (`attachment.motionUnsubscribe
|
|
3989
|
+
* !== null` — see `runOccupancyBurst`). Frames feed in via
|
|
3990
|
+
* `teeOccupancyTapFrame`, called from `runMotionAnalysis` on every
|
|
3991
|
+
* arriving substream frame; NO new main-stream poller/decode session is
|
|
3992
|
+
* started. A safety timer (mirrors the dial path's) completes the burst
|
|
3993
|
+
* even if the motion subscription stops producing frames mid-burst.
|
|
3994
|
+
*
|
|
3995
|
+
* Returns a promise that resolves once the tap settles (budget drained or
|
|
3996
|
+
* safety timeout) — `runOccupancyBurst` awaits it so the global
|
|
3997
|
+
* `occupancyActiveCount` slot stays held for the tap's full duration, the
|
|
3998
|
+
* same as the dial path.
|
|
3999
|
+
*/
|
|
4000
|
+
/**
|
|
4001
|
+
* Returns a promise that resolves with the tap's final `collected` count
|
|
4002
|
+
* once it settles (budget drained or safety timeout). The caller
|
|
4003
|
+
* (`runOccupancyBurst`) decides what to do with that count — report the
|
|
4004
|
+
* outcome via `finishOccupancyBurst` when frames were actually collected,
|
|
4005
|
+
* or fall back to a dial burst when the tap collected zero (Task 10
|
|
4006
|
+
* hardening #2). Resolving with the count rather than reporting from
|
|
4007
|
+
* inside the tap itself is what makes that fallback possible without
|
|
4008
|
+
* double-reporting.
|
|
4009
|
+
*/
|
|
4010
|
+
startOccupancyTap(deviceId, frames, log) {
|
|
4011
|
+
this.occupancyBurstInFlight.add(deviceId);
|
|
4012
|
+
this.occupancyBurstFound.delete(deviceId);
|
|
4013
|
+
log.debug("occupancy re-check: tapping live analyzer motion frames (no dial)", { meta: { frames } });
|
|
4014
|
+
return new Promise((resolve) => {
|
|
4015
|
+
const safetyTimer = setTimeout(() => {
|
|
4016
|
+
const collected = this.occupancyTaps.get(deviceId)?.collected ?? 0;
|
|
4017
|
+
log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
|
|
4018
|
+
this.finishOccupancyTap(deviceId);
|
|
4019
|
+
}, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
|
|
4020
|
+
this.occupancyTaps.set(deviceId, {
|
|
4021
|
+
remaining: frames,
|
|
4022
|
+
collected: 0,
|
|
4023
|
+
safetyTimer,
|
|
4024
|
+
resolve
|
|
4025
|
+
});
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
4028
|
+
/** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
|
|
4029
|
+
finishOccupancyTap(deviceId) {
|
|
4030
|
+
const tap = this.occupancyTaps.get(deviceId);
|
|
4031
|
+
if (!tap) return;
|
|
4032
|
+
clearTimeout(tap.safetyTimer);
|
|
4033
|
+
this.occupancyTaps.delete(deviceId);
|
|
4034
|
+
tap.resolve(tap.collected);
|
|
4035
|
+
}
|
|
4036
|
+
/**
|
|
4037
|
+
* Tee an arriving analyzer motion frame into occupancy-burst detection
|
|
4038
|
+
* admission (Task 10). Called from `runMotionAnalysis` on EVERY
|
|
4039
|
+
* substream frame; a no-op unless `startOccupancyTap` armed a budget for
|
|
4040
|
+
* this device. The frame always feeds `enqueueOccupancyFrame` — the
|
|
4041
|
+
* handle may be `undefined` (e.g. the Epic-C per-session motion decode
|
|
4042
|
+
* worker never produces a shm handle) and `runInference` already falls
|
|
4043
|
+
* back to the pixel path in that case, so every arriving frame counts
|
|
4044
|
+
* toward the budget regardless of whether it carried a zero-copy handle.
|
|
4045
|
+
*/
|
|
4046
|
+
teeOccupancyTapFrame(deviceId, frame, handle) {
|
|
4047
|
+
const tap = this.occupancyTaps.get(deviceId);
|
|
4048
|
+
if (!tap) return;
|
|
4049
|
+
this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
|
|
4050
|
+
tap.collected++;
|
|
4051
|
+
tap.remaining--;
|
|
4052
|
+
if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
|
|
4053
|
+
}
|
|
3446
4054
|
async runOccupancyBurst(deviceId, frames) {
|
|
3447
4055
|
if (this.occupancyBurstInFlight.has(deviceId)) return;
|
|
3448
4056
|
const attachment = this.attached.get(deviceId);
|
|
3449
4057
|
if (!attachment) return;
|
|
3450
|
-
const config = attachment.config;
|
|
3451
4058
|
const runner = this.runner;
|
|
3452
4059
|
if (!runner) return;
|
|
3453
4060
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
@@ -3456,8 +4063,40 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
3456
4063
|
log.debug("runOccupancyBurst: ctx.api not available");
|
|
3457
4064
|
return;
|
|
3458
4065
|
}
|
|
3459
|
-
this.
|
|
3460
|
-
|
|
4066
|
+
const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
|
|
4067
|
+
const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
|
|
4068
|
+
if (attachment.motionUnsubscribe !== null && motionFresh) {
|
|
4069
|
+
const collected = await this.startOccupancyTap(deviceId, frames, log);
|
|
4070
|
+
if (collected > 0) {
|
|
4071
|
+
this.finishOccupancyBurst(deviceId, log, collected);
|
|
4072
|
+
return;
|
|
4073
|
+
}
|
|
4074
|
+
log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
|
|
4075
|
+
await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, true);
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, false);
|
|
4079
|
+
}
|
|
4080
|
+
/**
|
|
4081
|
+
* Dial-path occupancy burst: subscribes a fresh main-stream decode session
|
|
4082
|
+
* via `startFrameHandlePoller` and collects `frames` handles (or times out
|
|
4083
|
+
* at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
|
|
4084
|
+
* from `runOccupancyBurst` (no live/fresh motion tap available,
|
|
4085
|
+
* `alreadyInFlight: false`) or as the Task 10 hardening #2 fallback after
|
|
4086
|
+
* a zero-collected tap (`alreadyInFlight: true` — the tap already claimed
|
|
4087
|
+
* the `occupancyBurstInFlight`/`occupancyBurstFound` slot, so this must
|
|
4088
|
+
* NOT re-add it).
|
|
4089
|
+
*/
|
|
4090
|
+
async runOccupancyDial(deviceId, frames, attachment, runner, api, log, alreadyInFlight) {
|
|
4091
|
+
const config = attachment.config;
|
|
4092
|
+
if (!alreadyInFlight) {
|
|
4093
|
+
this.occupancyBurstInFlight.add(deviceId);
|
|
4094
|
+
this.occupancyBurstFound.delete(deviceId);
|
|
4095
|
+
}
|
|
4096
|
+
log.debug("occupancy re-check: starting burst", { meta: {
|
|
4097
|
+
frames,
|
|
4098
|
+
fallback: alreadyInFlight
|
|
4099
|
+
} });
|
|
3461
4100
|
let count = 0;
|
|
3462
4101
|
let unsubscribe = null;
|
|
3463
4102
|
let unsubbed = false;
|
|
@@ -3472,13 +4111,12 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
3472
4111
|
if (settled) return;
|
|
3473
4112
|
settled = true;
|
|
3474
4113
|
doUnsub();
|
|
3475
|
-
this.
|
|
3476
|
-
log.debug("occupancy re-check: burst complete", { meta: { collected: count } });
|
|
4114
|
+
this.finishOccupancyBurst(deviceId, log, count);
|
|
3477
4115
|
};
|
|
3478
4116
|
const safetyTimer = setTimeout(() => {
|
|
3479
4117
|
log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
|
|
3480
4118
|
cleanup();
|
|
3481
|
-
},
|
|
4119
|
+
}, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
|
|
3482
4120
|
try {
|
|
3483
4121
|
unsubscribe = await startFrameHandlePoller({
|
|
3484
4122
|
api,
|
|
@@ -3502,6 +4140,7 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
3502
4140
|
} catch (err) {
|
|
3503
4141
|
clearTimeout(safetyTimer);
|
|
3504
4142
|
this.occupancyBurstInFlight.delete(deviceId);
|
|
4143
|
+
this.occupancyBurstFound.delete(deviceId);
|
|
3505
4144
|
log.debug("occupancy re-check: poller start failed", { meta: { error: errMsg(err) } });
|
|
3506
4145
|
return;
|
|
3507
4146
|
}
|
|
@@ -3513,12 +4152,14 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
3513
4152
|
emitInferenceResult(deviceId, frame, result, handle) {
|
|
3514
4153
|
if (!this.ctx?.eventBus) return;
|
|
3515
4154
|
const capturedAt = frame.capturedAt;
|
|
4155
|
+
const detailSteps = this.attached.get(deviceId)?.detailSteps;
|
|
3516
4156
|
const payload = {
|
|
3517
4157
|
deviceId,
|
|
3518
4158
|
frame: result,
|
|
3519
4159
|
nodeId: this.nodeId,
|
|
3520
4160
|
frameHandle: handle,
|
|
3521
|
-
...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
|
|
4161
|
+
...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {},
|
|
4162
|
+
...detailSteps && detailSteps.length > 0 ? { detailSteps } : {}
|
|
3522
4163
|
};
|
|
3523
4164
|
this.ctx.eventBus.emit(createEvent(EventCategory.PipelineInferenceResult, {
|
|
3524
4165
|
type: "device",
|
|
@@ -3675,4 +4316,4 @@ var PipelineRunnerAddon = class extends BaseAddon {
|
|
|
3675
4316
|
}
|
|
3676
4317
|
};
|
|
3677
4318
|
//#endregion
|
|
3678
|
-
export { DEFAULT_CONFIG, FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, acquireSessionDecodeRestreamFrom, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, resolveMotionFrameSource, resolveSessionDecodeHostname, resolveSessionDecodeOwnerNodeId, rgbFrameToGrayFrame, shouldStartOnboardAnalyzer };
|
|
4319
|
+
export { DEFAULT_CONFIG, FrameQueue, PipelineRunner, PipelineTimingSampler, Semaphore, acquireSessionDecodeRestreamFrom, pipelineRunnerBenchActions as customActions, PipelineRunnerAddon as default, deriveDetailSteps, resolveMotionFrameSource, resolveSessionDecodeHostname, resolveSessionDecodeOwnerNodeId, rgbFrameToGrayFrame, shouldStartOnboardAnalyzer };
|