@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
|
@@ -2,10 +2,11 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
6
|
-
const require_remote_source_plane = require("../remote-source-plane-
|
|
5
|
+
const require_dist = require("../dist-DI57FC8K.js");
|
|
6
|
+
const require_remote_source_plane = require("../remote-source-plane-CHgvzzA6.js");
|
|
7
7
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
8
|
-
const require_worker_protocol = require("../worker-protocol-
|
|
8
|
+
const require_worker_protocol = require("../worker-protocol-PP4jKHHJ.js");
|
|
9
|
+
const require_step_definitions = require("../step-definitions-CNBFKjZe.js");
|
|
9
10
|
let _camstack_shm_ring = require("@camstack/shm-ring");
|
|
10
11
|
let node_child_process = require("node:child_process");
|
|
11
12
|
let node_url = require("node:url");
|
|
@@ -427,6 +428,36 @@ function startPolling(options, source, subscriptionId, resolvedMaxFps, lifecycle
|
|
|
427
428
|
};
|
|
428
429
|
}
|
|
429
430
|
//#endregion
|
|
431
|
+
//#region src/pipeline-runner/frame-governor.ts
|
|
432
|
+
var FrameGovernor = class {
|
|
433
|
+
config;
|
|
434
|
+
intervals = /* @__PURE__ */ new Map();
|
|
435
|
+
throttled = 0;
|
|
436
|
+
constructor(config) {
|
|
437
|
+
this.config = config;
|
|
438
|
+
}
|
|
439
|
+
update(samples) {
|
|
440
|
+
this.intervals.clear();
|
|
441
|
+
this.throttled = 0;
|
|
442
|
+
if (samples.size === 0) return;
|
|
443
|
+
const shareMsPerSec = this.config.targetLoadPercent / 100 * this.config.maxConcurrentInferences * 1e3 / samples.size;
|
|
444
|
+
for (const [deviceId, s] of samples) {
|
|
445
|
+
const ceilingFps = s.configuredFps > 0 ? s.configuredFps : s.decodeFps;
|
|
446
|
+
const affordableFps = s.avgInferenceMs > 0 ? shareMsPerSec / s.avgInferenceMs : ceilingFps;
|
|
447
|
+
const targetFps = Math.min(ceilingFps, Math.max(this.config.minThrottledFps, affordableFps));
|
|
448
|
+
if (targetFps < ceilingFps) this.throttled++;
|
|
449
|
+
this.intervals.set(deviceId, targetFps > 0 ? 1e3 / targetFps : 0);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/** Minimum ms between admitted frames; 0 = no gate. */
|
|
453
|
+
minIntervalMs(deviceId) {
|
|
454
|
+
return this.intervals.get(deviceId) ?? 0;
|
|
455
|
+
}
|
|
456
|
+
throttledCount() {
|
|
457
|
+
return this.throttled;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
//#endregion
|
|
430
461
|
//#region src/pipeline-runner/frame-queue.ts
|
|
431
462
|
/**
|
|
432
463
|
* Latest-frame-only buffer. Keeps only the most recent item, dropping all
|
|
@@ -608,6 +639,24 @@ var PipelineTimingSampler = class {
|
|
|
608
639
|
if (!this.detSamples.has(deviceId)) this.detSamples.set(deviceId, []);
|
|
609
640
|
this.detSamples.get(deviceId).push(s);
|
|
610
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* Moving average inference time (ms) per camera over the current
|
|
644
|
+
* accumulation window — the same `inf` samples the `pipeline stats` log
|
|
645
|
+
* line aggregates (see {@link report}'s `inf` computation). Consumed by
|
|
646
|
+
* the {@link FrameGovernor} recompute tick as `CameraLoadSample.avgInferenceMs`.
|
|
647
|
+
* The window resets on every {@link report} call (every REPORT_INTERVAL_MS),
|
|
648
|
+
* so a camera with zero completed inferences since the last report is
|
|
649
|
+
* simply absent from the returned map — callers supply their own default.
|
|
650
|
+
*/
|
|
651
|
+
avgInferenceMsByCamera() {
|
|
652
|
+
const result = /* @__PURE__ */ new Map();
|
|
653
|
+
for (const [deviceId, samples] of this.detSamples) {
|
|
654
|
+
if (samples.length === 0) continue;
|
|
655
|
+
const total = samples.reduce((sum, s) => sum + s.inference, 0);
|
|
656
|
+
result.set(deviceId, total / samples.length);
|
|
657
|
+
}
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
611
660
|
addMotionSample(deviceId, ms, frameAge = -1) {
|
|
612
661
|
if (!this.motSamples.has(deviceId)) this.motSamples.set(deviceId, []);
|
|
613
662
|
this.motSamples.get(deviceId).push({
|
|
@@ -785,6 +834,8 @@ function ownMotionEntry(entry) {
|
|
|
785
834
|
*/
|
|
786
835
|
var HANDLE_PASSTHROUGH_MAX_QUEUE_DEPTH = 1;
|
|
787
836
|
var DEFAULT_MOTION_COOLDOWN_MS = 3e4;
|
|
837
|
+
var GOVERNOR_RECOMPUTE_INTERVAL_MS = 2e3;
|
|
838
|
+
var DEFAULT_DECODE_FPS = 25;
|
|
788
839
|
function toFrameInput$1(frame) {
|
|
789
840
|
return {
|
|
790
841
|
data: frame.data,
|
|
@@ -820,10 +871,20 @@ var PipelineRunner = class {
|
|
|
820
871
|
detectionStreamHandler = null;
|
|
821
872
|
logger;
|
|
822
873
|
timingSampler = new PipelineTimingSampler();
|
|
874
|
+
governor;
|
|
875
|
+
lastAdmittedAt = /* @__PURE__ */ new Map();
|
|
876
|
+
arrivalCounts = /* @__PURE__ */ new Map();
|
|
877
|
+
lastGovernorRecomputeAt = Date.now();
|
|
878
|
+
governorIntervalHandle = null;
|
|
823
879
|
constructor(config) {
|
|
824
880
|
this.config = config;
|
|
825
881
|
this.logger = config.logger;
|
|
826
882
|
this.semaphore = new Semaphore(config.maxConcurrentInferences);
|
|
883
|
+
this.governor = new FrameGovernor({
|
|
884
|
+
targetLoadPercent: config.targetLoadPercent,
|
|
885
|
+
minThrottledFps: config.minThrottledFps,
|
|
886
|
+
maxConcurrentInferences: config.maxConcurrentInferences
|
|
887
|
+
});
|
|
827
888
|
}
|
|
828
889
|
/**
|
|
829
890
|
* Hot-reload the four tuning fields without tearing down the runner.
|
|
@@ -834,8 +895,15 @@ var PipelineRunner = class {
|
|
|
834
895
|
* (the FrameQueue implementation is latest-only and ignores maxSize
|
|
835
896
|
* anyway — see `frame-queue.ts` — so the field is effectively a
|
|
836
897
|
* metadata hint for observability).
|
|
837
|
-
* - `targetLoadPercent` / `minThrottledFps`: stored
|
|
838
|
-
*
|
|
898
|
+
* - `targetLoadPercent` / `minThrottledFps`: stored on `this.config` (read
|
|
899
|
+
* by `getLimits()`), but NOT re-fed into the live `FrameGovernor` — the
|
|
900
|
+
* governor is built once, at construction, from the config snapshot
|
|
901
|
+
* passed to the constructor. A hot patch to either knob changes what
|
|
902
|
+
* `getLimits()` reports but does not change live admission pacing until
|
|
903
|
+
* the runner is reconstructed. (Rebuilding the governor live is a
|
|
904
|
+
* follow-up, not in scope here — `maxConcurrentInferences` has the same
|
|
905
|
+
* gap: the semaphore resizes live but the governor's budget calc keeps
|
|
906
|
+
* the construction-time value.)
|
|
839
907
|
*
|
|
840
908
|
* Only keys present in the patch are overwritten; unspecified keys
|
|
841
909
|
* retain their current value. Any illegal combination (e.g.
|
|
@@ -881,13 +949,16 @@ var PipelineRunner = class {
|
|
|
881
949
|
lastArmedSource: null,
|
|
882
950
|
lastArmedRegions: void 0,
|
|
883
951
|
occupancyTimer: null,
|
|
884
|
-
motionInFlight: false
|
|
952
|
+
motionInFlight: false,
|
|
953
|
+
lastResultDetections: 0,
|
|
954
|
+
occupancyArmed: false,
|
|
955
|
+
emptyBurstStreak: 0
|
|
885
956
|
};
|
|
886
957
|
this.cameras.set(deviceId, state);
|
|
887
958
|
this.timingSampler.setDroppedFramesSource(deviceId, () => detectionQueue.droppedFrames);
|
|
888
959
|
if (registration.detectionMode === "on-motion") this.defaultRoundRobinKeys.push(deviceId);
|
|
889
960
|
if (registration.detectionMode === "on-motion" && registration.occupancyRecheckEnabled === true && (registration.occupancyRecheckSec ?? 0) > 0) state.occupancyTimer = setInterval(() => {
|
|
890
|
-
if (state.phase === "watching") this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
|
|
961
|
+
if (state.phase === "watching" && state.occupancyArmed) this.config.onOccupancyRecheck?.(deviceId, registration.occupancyRecheckFrames ?? 4);
|
|
891
962
|
}, (registration.occupancyRecheckSec ?? 0) * 1e3);
|
|
892
963
|
if (initialPhase === "active") {
|
|
893
964
|
this.detectionStreamHandler?.(deviceId, "subscribe");
|
|
@@ -916,6 +987,8 @@ var PipelineRunner = class {
|
|
|
916
987
|
state.detectionQueue.clear();
|
|
917
988
|
this.timingSampler.clearDroppedFramesSource(deviceId);
|
|
918
989
|
this.cameras.delete(deviceId);
|
|
990
|
+
this.lastAdmittedAt.delete(deviceId);
|
|
991
|
+
this.arrivalCounts.delete(deviceId);
|
|
919
992
|
const idx = this.defaultRoundRobinKeys.indexOf(deviceId);
|
|
920
993
|
if (idx !== -1) {
|
|
921
994
|
this.defaultRoundRobinKeys.splice(idx, 1);
|
|
@@ -933,13 +1006,29 @@ var PipelineRunner = class {
|
|
|
933
1006
|
enqueueDetectionFrame(deviceId, frame, handle) {
|
|
934
1007
|
const state = this.cameras.get(deviceId);
|
|
935
1008
|
if (!state) return;
|
|
1009
|
+
this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
|
|
936
1010
|
if (state.phase !== "active") return;
|
|
1011
|
+
const interval = this.governor.minIntervalMs(deviceId);
|
|
1012
|
+
if (interval > 0) {
|
|
1013
|
+
const last = this.lastAdmittedAt.get(deviceId) ?? 0;
|
|
1014
|
+
const now = Date.now();
|
|
1015
|
+
if (now - last < interval) return;
|
|
1016
|
+
this.lastAdmittedAt.set(deviceId, now);
|
|
1017
|
+
}
|
|
937
1018
|
frame._enqueuedAt = Date.now();
|
|
938
1019
|
state.detectionQueue.enqueue({
|
|
939
1020
|
frame,
|
|
940
1021
|
handle
|
|
941
1022
|
});
|
|
942
1023
|
}
|
|
1024
|
+
/**
|
|
1025
|
+
* `handle` is optional (mirrors {@link DetectionQueueEntry.handle}) — the
|
|
1026
|
+
* dial path (`runOccupancyBurst`'s `startFrameHandlePoller`) always has a
|
|
1027
|
+
* real shm handle, but Task 10's analyzer-tap path can feed a frame whose
|
|
1028
|
+
* motion subscription never produced one (e.g. the per-session motion
|
|
1029
|
+
* decode worker). `runInference` already falls back to the pixel path
|
|
1030
|
+
* when the handle is absent.
|
|
1031
|
+
*/
|
|
943
1032
|
enqueueOccupancyFrame(deviceId, frame, handle) {
|
|
944
1033
|
const state = this.cameras.get(deviceId);
|
|
945
1034
|
if (!state) return;
|
|
@@ -992,6 +1081,42 @@ var PipelineRunner = class {
|
|
|
992
1081
|
getPhase(deviceId) {
|
|
993
1082
|
return this.cameras.get(deviceId)?.phase;
|
|
994
1083
|
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Record the detection count from the runner's most recent inference
|
|
1086
|
+
* result for this camera. The addon wrapping the scheduler calls this
|
|
1087
|
+
* from its `onResult` callback — the runner itself stays event-agnostic
|
|
1088
|
+
* (it never inspects `FrameResult` shapes), it only remembers the raw
|
|
1089
|
+
* count that `transitionToWatching` reads to decide whether the
|
|
1090
|
+
* occupancy recheck timer arms for the upcoming `watching` phase.
|
|
1091
|
+
*/
|
|
1092
|
+
noteDetectionResult(deviceId, detectionCount) {
|
|
1093
|
+
const state = this.cameras.get(deviceId);
|
|
1094
|
+
if (!state) return;
|
|
1095
|
+
state.lastResultDetections = detectionCount;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Report the outcome of a completed occupancy recheck burst (see
|
|
1099
|
+
* `RunnerConfig.onOccupancyRecheck`). Called by the addon once it knows
|
|
1100
|
+
* whether any burst frame's inference result had detections.
|
|
1101
|
+
*
|
|
1102
|
+
* - `found: true` — resets the empty-burst streak. The timer stays armed;
|
|
1103
|
+
* a genuine occupant is a separate `reportMotion` event that reopens the
|
|
1104
|
+
* session through the existing motion/dispatch path — this method does
|
|
1105
|
+
* not itself flip the phase.
|
|
1106
|
+
* - `found: false` — increments the streak; after 2 consecutive empty
|
|
1107
|
+
* bursts the timer disarms (`occupancyArmed = false`) until the next
|
|
1108
|
+
* active→watching transition re-evaluates from scratch.
|
|
1109
|
+
*/
|
|
1110
|
+
reportOccupancyBurstOutcome(deviceId, found) {
|
|
1111
|
+
const state = this.cameras.get(deviceId);
|
|
1112
|
+
if (!state) return;
|
|
1113
|
+
if (found) {
|
|
1114
|
+
state.emptyBurstStreak = 0;
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
state.emptyBurstStreak++;
|
|
1118
|
+
if (state.emptyBurstStreak >= 2) state.occupancyArmed = false;
|
|
1119
|
+
}
|
|
995
1120
|
onResult(callback) {
|
|
996
1121
|
this.resultCallbacks.push(callback);
|
|
997
1122
|
}
|
|
@@ -999,12 +1124,19 @@ var PipelineRunner = class {
|
|
|
999
1124
|
if (this.intervalHandle !== null) return;
|
|
1000
1125
|
this.intervalHandle = setInterval(() => this.tick(), 10);
|
|
1001
1126
|
this.timingSampler.start();
|
|
1127
|
+
this.lastGovernorRecomputeAt = Date.now();
|
|
1128
|
+
this.governorIntervalHandle = setInterval(() => this.recomputeGovernor(), GOVERNOR_RECOMPUTE_INTERVAL_MS);
|
|
1129
|
+
this.governorIntervalHandle?.unref?.();
|
|
1002
1130
|
}
|
|
1003
1131
|
stop() {
|
|
1004
1132
|
if (this.intervalHandle !== null) {
|
|
1005
1133
|
clearInterval(this.intervalHandle);
|
|
1006
1134
|
this.intervalHandle = null;
|
|
1007
1135
|
}
|
|
1136
|
+
if (this.governorIntervalHandle !== null) {
|
|
1137
|
+
clearInterval(this.governorIntervalHandle);
|
|
1138
|
+
this.governorIntervalHandle = null;
|
|
1139
|
+
}
|
|
1008
1140
|
this.timingSampler.stop();
|
|
1009
1141
|
for (const state of this.cameras.values()) {
|
|
1010
1142
|
if (state.motionCooldownTimer !== null) {
|
|
@@ -1017,6 +1149,31 @@ var PipelineRunner = class {
|
|
|
1017
1149
|
}
|
|
1018
1150
|
}
|
|
1019
1151
|
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Recompute the {@link FrameGovernor}'s per-camera admission intervals
|
|
1154
|
+
* from fresh sampler + arrival data. Called on the {@link
|
|
1155
|
+
* GOVERNOR_RECOMPUTE_INTERVAL_MS} tick started by `start()`; also exposed
|
|
1156
|
+
* so tests can prime the governor deterministically without waiting on a
|
|
1157
|
+
* timer. `now` defaults to the wall clock but is accepted explicitly so a
|
|
1158
|
+
* caller can pass a fixed instant for reproducible elapsed-time math.
|
|
1159
|
+
*/
|
|
1160
|
+
recomputeGovernor(now = Date.now()) {
|
|
1161
|
+
const elapsedSec = Math.max((now - this.lastGovernorRecomputeAt) / 1e3, .001);
|
|
1162
|
+
const avgInferenceMsByCamera = this.timingSampler.avgInferenceMsByCamera();
|
|
1163
|
+
const samples = /* @__PURE__ */ new Map();
|
|
1164
|
+
for (const [deviceId, state] of this.cameras) {
|
|
1165
|
+
if (state.phase !== "active") continue;
|
|
1166
|
+
const measuredDecodeFps = (this.arrivalCounts.get(deviceId) ?? 0) / elapsedSec;
|
|
1167
|
+
samples.set(deviceId, {
|
|
1168
|
+
avgInferenceMs: avgInferenceMsByCamera.get(deviceId) ?? 0,
|
|
1169
|
+
configuredFps: state.registration.fps,
|
|
1170
|
+
decodeFps: measuredDecodeFps > 0 ? measuredDecodeFps : DEFAULT_DECODE_FPS
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
this.governor.update(samples);
|
|
1174
|
+
this.arrivalCounts.clear();
|
|
1175
|
+
this.lastGovernorRecomputeAt = now;
|
|
1176
|
+
}
|
|
1020
1177
|
getMetrics() {
|
|
1021
1178
|
let totalQueueDepth = 0;
|
|
1022
1179
|
let totalInferenceTime = 0;
|
|
@@ -1030,7 +1187,7 @@ var PipelineRunner = class {
|
|
|
1030
1187
|
}
|
|
1031
1188
|
return {
|
|
1032
1189
|
activeCameras: this.cameras.size,
|
|
1033
|
-
throttledCameras:
|
|
1190
|
+
throttledCameras: this.governor.throttledCount(),
|
|
1034
1191
|
avgInferenceTimeMs: totalInferenceCount > 0 ? totalInferenceTime / totalInferenceCount : 0,
|
|
1035
1192
|
queueDepth: totalQueueDepth
|
|
1036
1193
|
};
|
|
@@ -1068,6 +1225,7 @@ var PipelineRunner = class {
|
|
|
1068
1225
|
}
|
|
1069
1226
|
transitionToActive(deviceId, state, source, regions, cooldownMs) {
|
|
1070
1227
|
state.phase = "active";
|
|
1228
|
+
state.lastResultDetections = 0;
|
|
1071
1229
|
this.logger?.info("motion gate opened — phase=active", {
|
|
1072
1230
|
tags: { deviceId },
|
|
1073
1231
|
meta: {
|
|
@@ -1086,9 +1244,14 @@ var PipelineRunner = class {
|
|
|
1086
1244
|
transitionToWatching(deviceId, state, cooldownMs) {
|
|
1087
1245
|
state.phase = "watching";
|
|
1088
1246
|
state.detectionQueue.clear();
|
|
1247
|
+
state.occupancyArmed = state.lastResultDetections > 0;
|
|
1248
|
+
state.emptyBurstStreak = 0;
|
|
1089
1249
|
this.logger?.info("motion gate closed — phase=watching", {
|
|
1090
1250
|
tags: { deviceId },
|
|
1091
|
-
meta: {
|
|
1251
|
+
meta: {
|
|
1252
|
+
lastSource: state.lastArmedSource,
|
|
1253
|
+
occupancyArmed: state.occupancyArmed
|
|
1254
|
+
}
|
|
1092
1255
|
});
|
|
1093
1256
|
this.detectionStreamHandler?.(deviceId, "unsubscribe");
|
|
1094
1257
|
const source = state.lastArmedSource ?? "analyzer";
|
|
@@ -1225,9 +1388,11 @@ var KILL_SIGNALS = {
|
|
|
1225
1388
|
};
|
|
1226
1389
|
/** Adapt a Node {@link ChildProcess} to the coordinator's {@link ForkedChildLike}. */
|
|
1227
1390
|
function wrapChild(child) {
|
|
1391
|
+
child.on("error", () => void 0);
|
|
1228
1392
|
return {
|
|
1229
1393
|
send: (msg) => {
|
|
1230
|
-
child.
|
|
1394
|
+
if (!child.connected) return;
|
|
1395
|
+
child.send(msg, () => void 0);
|
|
1231
1396
|
},
|
|
1232
1397
|
on: (ev, cb) => {
|
|
1233
1398
|
if (ev === "exit") child.on("exit", () => cb(void 0));
|
|
@@ -1546,6 +1711,18 @@ var SessionDecodeSession = class {
|
|
|
1546
1711
|
}
|
|
1547
1712
|
});
|
|
1548
1713
|
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Prompt-release the worker's RAM native-frame lease for `frameId`. Fire and
|
|
1716
|
+
* forget (no reply): a lease also self-expires on its TTL, so a dropped
|
|
1717
|
+
* release only defers reclaim. No-op once the worker has exited.
|
|
1718
|
+
*/
|
|
1719
|
+
releaseLease(frameId) {
|
|
1720
|
+
if (this.exited) return;
|
|
1721
|
+
this.trySend({
|
|
1722
|
+
kind: "releaseNativeLease",
|
|
1723
|
+
frameId
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1549
1726
|
/** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
|
|
1550
1727
|
teardown() {
|
|
1551
1728
|
this.clearPullTimeout();
|
|
@@ -1753,6 +1930,7 @@ function buildFrameImage(session, reply) {
|
|
|
1753
1930
|
return session.requestBuffer(reply.frameId, opts);
|
|
1754
1931
|
},
|
|
1755
1932
|
nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
|
|
1933
|
+
releaseNativeLease: () => session.releaseLease(reply.frameId),
|
|
1756
1934
|
close: () => {
|
|
1757
1935
|
closed = true;
|
|
1758
1936
|
}
|
|
@@ -1894,7 +2072,8 @@ function startSessionDecodePump(deps) {
|
|
|
1894
2072
|
try {
|
|
1895
2073
|
const decoded = await toDecodedFrame(frame, format);
|
|
1896
2074
|
const image = frame.image;
|
|
1897
|
-
const
|
|
2075
|
+
const cropFn = image.nativeCrop;
|
|
2076
|
+
const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), { release: () => image.releaseNativeLease?.() }) : void 0;
|
|
1898
2077
|
deps.onDecodedFrame(decoded, nativeCrop);
|
|
1899
2078
|
} catch (err) {
|
|
1900
2079
|
deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
|
|
@@ -1928,6 +2107,12 @@ function startSessionDecodePump(deps) {
|
|
|
1928
2107
|
iterator.return().catch(() => {});
|
|
1929
2108
|
};
|
|
1930
2109
|
}
|
|
2110
|
+
/** Invoke a requester's best-effort `release()`, swallowing any transport error. */
|
|
2111
|
+
function safeRelease(request) {
|
|
2112
|
+
try {
|
|
2113
|
+
request.release();
|
|
2114
|
+
} catch {}
|
|
2115
|
+
}
|
|
1931
2116
|
var NativeCropRegistry = class {
|
|
1932
2117
|
capacity;
|
|
1933
2118
|
entries = /* @__PURE__ */ new Map();
|
|
@@ -1942,7 +2127,10 @@ var NativeCropRegistry = class {
|
|
|
1942
2127
|
key(handle) {
|
|
1943
2128
|
return `${handle.shmId}#${handle.slot}#${handle.seq}`;
|
|
1944
2129
|
}
|
|
1945
|
-
/**
|
|
2130
|
+
/**
|
|
2131
|
+
* Bind `request` to `handle`; evicts the oldest entry past the cap, releasing
|
|
2132
|
+
* its worker-side RAM lease so a lease never outlives its registry entry.
|
|
2133
|
+
*/
|
|
1946
2134
|
register(handle, request) {
|
|
1947
2135
|
const k = this.key(handle);
|
|
1948
2136
|
this.entries.delete(k);
|
|
@@ -1950,19 +2138,173 @@ var NativeCropRegistry = class {
|
|
|
1950
2138
|
while (this.entries.size > this.capacity) {
|
|
1951
2139
|
const oldest = this.entries.keys().next().value;
|
|
1952
2140
|
if (oldest === void 0) break;
|
|
1953
|
-
this.
|
|
2141
|
+
this.releaseEntry(oldest);
|
|
1954
2142
|
}
|
|
1955
2143
|
}
|
|
1956
2144
|
/** The requester for `handle`, or `null` if never registered / already evicted. */
|
|
1957
2145
|
get(handle) {
|
|
1958
2146
|
return this.entries.get(this.key(handle)) ?? null;
|
|
1959
2147
|
}
|
|
1960
|
-
/**
|
|
2148
|
+
/**
|
|
2149
|
+
* Release the worker-side RAM native lease for `handle` and drop its entry —
|
|
2150
|
+
* the caller is done cutting native crops from that frame. Best-effort +
|
|
2151
|
+
* idempotent (an unknown handle is a no-op; the lease also self-expires).
|
|
2152
|
+
*/
|
|
2153
|
+
release(handle) {
|
|
2154
|
+
this.releaseEntry(this.key(handle));
|
|
2155
|
+
}
|
|
2156
|
+
/** Drop every entry, releasing each frame's worker-side lease (node/addon shutdown). */
|
|
1961
2157
|
clear() {
|
|
2158
|
+
for (const request of this.entries.values()) safeRelease(request);
|
|
1962
2159
|
this.entries.clear();
|
|
1963
2160
|
}
|
|
2161
|
+
/** Release + drop one entry by its map key. */
|
|
2162
|
+
releaseEntry(key) {
|
|
2163
|
+
const request = this.entries.get(key);
|
|
2164
|
+
if (!request) return;
|
|
2165
|
+
this.entries.delete(key);
|
|
2166
|
+
safeRelease(request);
|
|
2167
|
+
}
|
|
1964
2168
|
};
|
|
1965
2169
|
//#endregion
|
|
2170
|
+
//#region src/pipeline-runner/detail-subtree.ts
|
|
2171
|
+
/** Relative padding applied to the parent bbox before cutting the crop — more context for the child model. */
|
|
2172
|
+
var DETAIL_CROP_PADDING_RATIO = .15;
|
|
2173
|
+
/**
|
|
2174
|
+
* Pad `bbox` by {@link DETAIL_CROP_PADDING_RATIO} of its own size, then clamp
|
|
2175
|
+
* to `[0, frameWidth] x [0, frameHeight]`. Pure — always returns a new rect.
|
|
2176
|
+
*/
|
|
2177
|
+
function padAndClampFrameBbox(bbox, frameWidth, frameHeight) {
|
|
2178
|
+
const padX = DETAIL_CROP_PADDING_RATIO * bbox.w;
|
|
2179
|
+
const padY = DETAIL_CROP_PADDING_RATIO * bbox.h;
|
|
2180
|
+
const x1 = Math.max(0, bbox.x - padX);
|
|
2181
|
+
const y1 = Math.max(0, bbox.y - padY);
|
|
2182
|
+
const x2 = Math.min(frameWidth, bbox.x + bbox.w + padX);
|
|
2183
|
+
const y2 = Math.min(frameHeight, bbox.y + bbox.h + padY);
|
|
2184
|
+
return {
|
|
2185
|
+
x: x1,
|
|
2186
|
+
y: y1,
|
|
2187
|
+
w: Math.max(0, x2 - x1),
|
|
2188
|
+
h: Math.max(0, y2 - y1)
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
/** Normalize a frame-space pixel rect to `[0,1]` by the given frame dims. */
|
|
2192
|
+
function normalizeRect(rect, frameWidth, frameHeight) {
|
|
2193
|
+
return {
|
|
2194
|
+
x: rect.x / frameWidth,
|
|
2195
|
+
y: rect.y / frameHeight,
|
|
2196
|
+
w: rect.w / frameWidth,
|
|
2197
|
+
h: rect.h / frameHeight
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
/** Inverse of {@link normalizeRect} — denormalize a `[0,1]` rect back to frame-space pixels. */
|
|
2201
|
+
function denormalizeRect(rect, frameWidth, frameHeight) {
|
|
2202
|
+
return {
|
|
2203
|
+
x: rect.x * frameWidth,
|
|
2204
|
+
y: rect.y * frameHeight,
|
|
2205
|
+
w: rect.w * frameWidth,
|
|
2206
|
+
h: rect.h * frameHeight
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* Walk `steps` (the attach config's tree) collecting every ENABLED step
|
|
2211
|
+
* whose catalog definition's `inputClasses` includes `parentClassName` —
|
|
2212
|
+
* these become the subtree's roots. A matched step's own nested `children`
|
|
2213
|
+
* ride along UNCHANGED (transitively included: `face-detection` pulls
|
|
2214
|
+
* `face-embedding`, `plate-detection` pulls `plate-ocr`) — recursion stops
|
|
2215
|
+
* at a match rather than also searching inside it for further matches.
|
|
2216
|
+
* `filter`, when given, keeps only matches whose `addonId` is in the list.
|
|
2217
|
+
*/
|
|
2218
|
+
function findMatchingDetailSteps(steps, parentClassName, lookupStepDefinition, filter) {
|
|
2219
|
+
const matches = [];
|
|
2220
|
+
const walk = (list) => {
|
|
2221
|
+
for (const step of list) {
|
|
2222
|
+
if (!step.enabled) continue;
|
|
2223
|
+
const definition = lookupStepDefinition(step.addonId);
|
|
2224
|
+
if (definition !== null && definition.inputClasses !== null && definition.inputClasses.includes(parentClassName)) {
|
|
2225
|
+
if (!filter || filter.includes(step.addonId)) matches.push(step);
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
2228
|
+
if (step.children) walk(step.children);
|
|
2229
|
+
}
|
|
2230
|
+
};
|
|
2231
|
+
walk(steps);
|
|
2232
|
+
return matches;
|
|
2233
|
+
}
|
|
2234
|
+
/** Map a detection's CROP-pixel bbox back to FRAME-space pixels. */
|
|
2235
|
+
function mapBboxToFrameSpace(bbox, crop) {
|
|
2236
|
+
const scaleX = crop.frameSpace.w / crop.width;
|
|
2237
|
+
const scaleY = crop.frameSpace.h / crop.height;
|
|
2238
|
+
return {
|
|
2239
|
+
x: crop.frameSpace.x + bbox.x * scaleX,
|
|
2240
|
+
y: crop.frameSpace.y + bbox.y * scaleY,
|
|
2241
|
+
w: bbox.width * scaleX,
|
|
2242
|
+
h: bbox.height * scaleY
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
/** Base64-encode a normalized float32 embedding vector. */
|
|
2246
|
+
function encodeEmbeddingBase64(embedding) {
|
|
2247
|
+
return Buffer.from(Float32Array.from(embedding).buffer).toString("base64");
|
|
2248
|
+
}
|
|
2249
|
+
/** First user-visible label — skips the opaque `embedding:*` marker (see `result-assembler.ts`). */
|
|
2250
|
+
function pickLabel(detection) {
|
|
2251
|
+
return detection.labels.find((l) => !l.label.startsWith("embedding:"))?.label;
|
|
2252
|
+
}
|
|
2253
|
+
function toDetailResult(detection, stepId, crop) {
|
|
2254
|
+
const label = pickLabel(detection);
|
|
2255
|
+
return {
|
|
2256
|
+
stepId,
|
|
2257
|
+
className: detection.macroClass,
|
|
2258
|
+
score: detection.score,
|
|
2259
|
+
bbox: mapBboxToFrameSpace(detection.bbox, crop),
|
|
2260
|
+
...detection.embedding !== void 0 ? { embedding: encodeEmbeddingBase64(detection.embedding) } : {},
|
|
2261
|
+
...label !== void 0 ? { label } : {},
|
|
2262
|
+
...detection.faceAlignedCrop !== void 0 ? { alignedCropJpeg: detection.faceAlignedCrop } : {}
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
async function resolveCrop(deps, input) {
|
|
2266
|
+
if (input.frameHandle) {
|
|
2267
|
+
const normalized = normalizeRect(padAndClampFrameBbox(input.parent.bbox, input.frameHandle.width, input.frameHandle.height), input.frameHandle.width, input.frameHandle.height);
|
|
2268
|
+
const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
|
|
2269
|
+
if (native) return native;
|
|
2270
|
+
}
|
|
2271
|
+
if (input.cropJpeg) {
|
|
2272
|
+
const jpeg = Buffer.from(input.cropJpeg, "base64");
|
|
2273
|
+
const sharp = (await import("sharp")).default;
|
|
2274
|
+
const meta = await sharp(jpeg).metadata();
|
|
2275
|
+
const width = meta.width ?? 0;
|
|
2276
|
+
const height = meta.height ?? 0;
|
|
2277
|
+
if (width <= 0 || height <= 0) return null;
|
|
2278
|
+
return {
|
|
2279
|
+
jpeg,
|
|
2280
|
+
width,
|
|
2281
|
+
height,
|
|
2282
|
+
frameSpace: input.parent.bbox
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
return null;
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* Run the DETAIL subtree for a single tracked detection: resolve a crop
|
|
2289
|
+
* (native lease → `cropJpeg` fallback), run each matched top-level child
|
|
2290
|
+
* step's chain against it, and map every result bbox back to frame space.
|
|
2291
|
+
* Returns `null` when the camera isn't attached, no configured child
|
|
2292
|
+
* matches `input.parent.className`, or no crop source resolves.
|
|
2293
|
+
*/
|
|
2294
|
+
async function runDetailSubtree(deps, input) {
|
|
2295
|
+
const steps = deps.resolveChildSteps(input.deviceId, input.parent.className, input.steps);
|
|
2296
|
+
if (!steps || steps.length === 0) return null;
|
|
2297
|
+
const crop = await resolveCrop(deps, input);
|
|
2298
|
+
if (!crop) return null;
|
|
2299
|
+
const details = [];
|
|
2300
|
+
for (const step of steps) {
|
|
2301
|
+
const result = await deps.runPipeline([step], crop.jpeg, input.deviceId);
|
|
2302
|
+
if (!result) continue;
|
|
2303
|
+
for (const detection of result.detections) details.push(toDetailResult(detection, step.addonId, crop));
|
|
2304
|
+
}
|
|
2305
|
+
return { details };
|
|
2306
|
+
}
|
|
2307
|
+
//#endregion
|
|
1966
2308
|
//#region src/pipeline-runner/index.ts
|
|
1967
2309
|
var DEFAULT_CONFIG = {
|
|
1968
2310
|
maxQueueDepth: 30,
|
|
@@ -1987,6 +2329,23 @@ var DEFAULT_CONFIG = {
|
|
|
1987
2329
|
*/
|
|
1988
2330
|
var DETECTION_KEEPWARM_MS = 15e3;
|
|
1989
2331
|
/**
|
|
2332
|
+
* Occupancy-burst safety timeout (both the dial path and the analyzer-tap
|
|
2333
|
+
* path — Task 10). Bounds worst-case burst latency when frames stop
|
|
2334
|
+
* arriving mid-burst — a dial that never gets a keyframe, or a tap whose
|
|
2335
|
+
* motion subscription closes before N frames arrive.
|
|
2336
|
+
*/
|
|
2337
|
+
var OCCUPANCY_BURST_SAFETY_TIMEOUT_MS = 8e3;
|
|
2338
|
+
/**
|
|
2339
|
+
* Freshness window for `lastMotionFrameAt` (Task 10 hardening). A non-null
|
|
2340
|
+
* `attachment.motionUnsubscribe` only proves a subscription handle exists —
|
|
2341
|
+
* NOT that frames are actually flowing (guarded-session-decode can return
|
|
2342
|
+
* its teardown synchronously, and a failed `acquireSessionDecodeRestreamFrom`
|
|
2343
|
+
* acquire never retries, leaving the handle permanently non-null with zero
|
|
2344
|
+
* frames arriving). `runOccupancyBurst` additionally requires a motion frame
|
|
2345
|
+
* to have been teed within this window before it will route to the tap path.
|
|
2346
|
+
*/
|
|
2347
|
+
var MOTION_FRESHNESS_WINDOW_MS = 5e3;
|
|
2348
|
+
/**
|
|
1990
2349
|
* Pure decision helper: returns `true` when a dynamic frame-diff analyzer
|
|
1991
2350
|
* subscription should be opened for an onboard-motion camera.
|
|
1992
2351
|
*
|
|
@@ -2003,6 +2362,62 @@ function shouldStartOnboardAnalyzer(config) {
|
|
|
2003
2362
|
if (config.motionSources.includes("analyzer")) return false;
|
|
2004
2363
|
return true;
|
|
2005
2364
|
}
|
|
2365
|
+
/** Recursively collects the `addonId` of every ENABLED step in the tree. */
|
|
2366
|
+
function collectEnabledStepIds(steps, acc) {
|
|
2367
|
+
if (!steps) return;
|
|
2368
|
+
for (const step of steps) {
|
|
2369
|
+
if (!step.enabled) continue;
|
|
2370
|
+
acc.add(step.addonId);
|
|
2371
|
+
collectEnabledStepIds(step.children, acc);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
/**
|
|
2375
|
+
* Looks up a catalog step definition, tolerating an unknown id. A stale
|
|
2376
|
+
* config entry (removed/custom addon) must degrade to "not announced"
|
|
2377
|
+
* rather than fail the attach — `getStepDefinition` throws on a miss.
|
|
2378
|
+
*/
|
|
2379
|
+
function tryGetStepDefinition(stepId) {
|
|
2380
|
+
try {
|
|
2381
|
+
return require_step_definitions.getStepDefinition(stepId);
|
|
2382
|
+
} catch {
|
|
2383
|
+
return null;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
/**
|
|
2387
|
+
* Precomputes the `detailSteps` announce (two-plane design) for a camera's
|
|
2388
|
+
* configured step tree: every ENABLED step whose catalog definition is a
|
|
2389
|
+
* detail/child step (`inputClasses !== null` — a root step like
|
|
2390
|
+
* `object-detection` runs on every frame on the `'frame'` plane and has
|
|
2391
|
+
* nothing to announce) is surfaced as `{ stepId, inputClasses, cadence }`
|
|
2392
|
+
* so a track-level consumer knows what it can dispatch via
|
|
2393
|
+
* `pipelineRunner.runDetailSubtree` and on what cadence, without a
|
|
2394
|
+
* separate round-trip to the executor's schema/config caps.
|
|
2395
|
+
*
|
|
2396
|
+
* Unknown step ids (stale config referencing a removed/custom addon) are
|
|
2397
|
+
* skipped rather than thrown — the catalog is the single source of truth
|
|
2398
|
+
* for cadence and a stale entry must not fail the attach.
|
|
2399
|
+
*
|
|
2400
|
+
* Extracted as a pure function so the unit suite can exercise the
|
|
2401
|
+
* derivation without instantiating the full addon.
|
|
2402
|
+
*/
|
|
2403
|
+
function deriveDetailSteps(steps) {
|
|
2404
|
+
const stepIds = /* @__PURE__ */ new Set();
|
|
2405
|
+
collectEnabledStepIds(steps, stepIds);
|
|
2406
|
+
const announces = [];
|
|
2407
|
+
for (const stepId of stepIds) {
|
|
2408
|
+
const def = tryGetStepDefinition(stepId);
|
|
2409
|
+
if (!def || def.inputClasses === null) continue;
|
|
2410
|
+
announces.push({
|
|
2411
|
+
stepId: def.id,
|
|
2412
|
+
inputClasses: def.inputClasses,
|
|
2413
|
+
cadence: def.cadence ?? {
|
|
2414
|
+
trigger: "once",
|
|
2415
|
+
maxPerTrack: 3
|
|
2416
|
+
}
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
return announces;
|
|
2420
|
+
}
|
|
2006
2421
|
function toFrameInput(frame) {
|
|
2007
2422
|
return {
|
|
2008
2423
|
data: frame.data,
|
|
@@ -2264,6 +2679,13 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
2264
2679
|
/** Per-camera single-flight: a camera never runs two occupancy bursts at once. */
|
|
2265
2680
|
occupancyBurstInFlight = /* @__PURE__ */ new Set();
|
|
2266
2681
|
/**
|
|
2682
|
+
* Devices whose in-flight occupancy burst has seen at least one inference
|
|
2683
|
+
* result with detections>0 (set from the `onResult` handler above, cleared
|
|
2684
|
+
* per-burst in `runOccupancyBurst`). Read at burst cleanup and reported to
|
|
2685
|
+
* `PipelineRunner.reportOccupancyBurstOutcome` — Task 9's arm/disarm signal.
|
|
2686
|
+
*/
|
|
2687
|
+
occupancyBurstFound = /* @__PURE__ */ new Set();
|
|
2688
|
+
/**
|
|
2267
2689
|
* GLOBAL occupancy admission queue. Each occupancy burst SUBSCRIBES a detection
|
|
2268
2690
|
* stream (rgb) which, during the `watching` phase, has no existing consumer and
|
|
2269
2691
|
* therefore makes the broker DIAL a fresh decoder. Without a global cap, N
|
|
@@ -2280,6 +2702,23 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
2280
2702
|
occupancyActiveCount = 0;
|
|
2281
2703
|
/** Max concurrent occupancy bursts (decoder dials) across all cameras. */
|
|
2282
2704
|
occupancyMaxConcurrent = 3;
|
|
2705
|
+
/**
|
|
2706
|
+
* TAP-based occupancy bursts in flight, keyed by deviceId (Task 10). See
|
|
2707
|
+
* {@link OccupancyTapState}. A device has an entry here XOR is dialing via
|
|
2708
|
+
* `startFrameHandlePoller` in `runOccupancyBurst` — never both; the choice
|
|
2709
|
+
* is made once at burst start based on `attachment.motionUnsubscribe`.
|
|
2710
|
+
*/
|
|
2711
|
+
occupancyTaps = /* @__PURE__ */ new Map();
|
|
2712
|
+
/**
|
|
2713
|
+
* Timestamp (`Date.now()`) of the last motion frame teed for each device
|
|
2714
|
+
* — set in `runMotionAnalysis` at the same call site as
|
|
2715
|
+
* `teeOccupancyTapFrame` (Task 10 hardening). This is the TRUTHFUL "is
|
|
2716
|
+
* the analyzer subscription actually delivering frames" signal;
|
|
2717
|
+
* `attachment.motionUnsubscribe !== null` alone only proves a
|
|
2718
|
+
* subscription handle was assigned, not that it's live. See
|
|
2719
|
+
* {@link MOTION_FRESHNESS_WINDOW_MS}.
|
|
2720
|
+
*/
|
|
2721
|
+
lastMotionFrameAt = /* @__PURE__ */ new Map();
|
|
2283
2722
|
constructor() {
|
|
2284
2723
|
super({ ...DEFAULT_CONFIG });
|
|
2285
2724
|
}
|
|
@@ -2316,6 +2755,9 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
2316
2755
|
});
|
|
2317
2756
|
this.runner.onResult(async (deviceId, frame, result, _streamType, handle) => {
|
|
2318
2757
|
this.emitInferenceResult(deviceId, frame, result, handle);
|
|
2758
|
+
const detectionCount = result.detections?.length ?? 0;
|
|
2759
|
+
this.runner?.noteDetectionResult(deviceId, detectionCount);
|
|
2760
|
+
if (detectionCount > 0 && this.occupancyBurstInFlight.has(deviceId)) this.occupancyBurstFound.add(deviceId);
|
|
2319
2761
|
});
|
|
2320
2762
|
this.runner.start();
|
|
2321
2763
|
this.ctx.logger.info("Pipeline runner started", {
|
|
@@ -2381,6 +2823,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
2381
2823
|
attachment.detectionUnsubscribe?.();
|
|
2382
2824
|
}
|
|
2383
2825
|
this.attached.clear();
|
|
2826
|
+
this.lastMotionFrameAt.clear();
|
|
2384
2827
|
}
|
|
2385
2828
|
async cacheBenchFrame(input) {
|
|
2386
2829
|
const sharp = (await import("sharp")).default;
|
|
@@ -2726,7 +3169,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
2726
3169
|
config,
|
|
2727
3170
|
motionUnsubscribe: null,
|
|
2728
3171
|
detectionUnsubscribe: null,
|
|
2729
|
-
detectionKeepWarmTimer: null
|
|
3172
|
+
detectionKeepWarmTimer: null,
|
|
3173
|
+
detailSteps: deriveDetailSteps(config.steps)
|
|
2730
3174
|
};
|
|
2731
3175
|
this.attached.set(config.deviceId, attachment);
|
|
2732
3176
|
runner.registerCamera(config.deviceId, {
|
|
@@ -2779,6 +3223,78 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
2779
3223
|
height: result.height
|
|
2780
3224
|
} : null;
|
|
2781
3225
|
}
|
|
3226
|
+
/**
|
|
3227
|
+
* Two-plane design: run the DETAIL subtree (crop children) for a single
|
|
3228
|
+
* tracked detection. Delegates the algorithm to the deps-injected pure
|
|
3229
|
+
* function in {@link runDetailSubtreeImpl} — this method only wires the
|
|
3230
|
+
* real deps: the attach config's step tree (+ the catalog, for the
|
|
3231
|
+
* `inputClasses` match), the SAME internal native-crop lease path
|
|
3232
|
+
* `getNativeCrop` uses above (no `ctx.api` round-trip — we're already in
|
|
3233
|
+
* the frame's owning process), and the node-local `pipelineExecutor`.
|
|
3234
|
+
*/
|
|
3235
|
+
async runDetailSubtree(input) {
|
|
3236
|
+
return runDetailSubtree({
|
|
3237
|
+
resolveChildSteps: (deviceId, parentClassName, filter) => this.resolveDetailChildSteps(deviceId, parentClassName, filter),
|
|
3238
|
+
getNativeCrop: (deviceId, handle, paddedBbox) => this.getNativeCropForDetail(deviceId, handle, paddedBbox),
|
|
3239
|
+
runPipeline: (steps, imageJpeg, deviceId) => this.runDetailPipeline(steps, imageJpeg, deviceId)
|
|
3240
|
+
}, input);
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Real `resolveChildSteps` dep: `null` when the camera isn't attached
|
|
3244
|
+
* (matches the cap doc — "camera not attached" is a distinct case from
|
|
3245
|
+
* "attached but no matching child steps", which returns an empty array
|
|
3246
|
+
* and short-circuits inside {@link runDetailSubtreeImpl} the same way).
|
|
3247
|
+
*/
|
|
3248
|
+
resolveDetailChildSteps(deviceId, parentClassName, filter) {
|
|
3249
|
+
const attachment = this.attached.get(deviceId);
|
|
3250
|
+
if (!attachment) return null;
|
|
3251
|
+
const steps = attachment.config.steps;
|
|
3252
|
+
if (!steps) return [];
|
|
3253
|
+
return findMatchingDetailSteps(steps, parentClassName, tryGetStepDefinition, filter);
|
|
3254
|
+
}
|
|
3255
|
+
/**
|
|
3256
|
+
* Real `getNativeCrop` dep: reuses the node-local {@link nativeCropRegistry}
|
|
3257
|
+
* lease (same path the `getNativeCrop` cap method above uses), encodes the
|
|
3258
|
+
* raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
|
|
3259
|
+
* pixels for the `frameSpace` the caller maps result bboxes against.
|
|
3260
|
+
*/
|
|
3261
|
+
async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
|
|
3262
|
+
const native = await this.getNativeCrop({
|
|
3263
|
+
handle,
|
|
3264
|
+
bbox: paddedBbox
|
|
3265
|
+
});
|
|
3266
|
+
if (!native) return null;
|
|
3267
|
+
const sharp = (await import("sharp")).default;
|
|
3268
|
+
return {
|
|
3269
|
+
jpeg: await sharp(Buffer.from(native.bytes), { raw: {
|
|
3270
|
+
width: native.width,
|
|
3271
|
+
height: native.height,
|
|
3272
|
+
channels: 3
|
|
3273
|
+
} }).jpeg({ quality: 90 }).toBuffer(),
|
|
3274
|
+
width: native.width,
|
|
3275
|
+
height: native.height,
|
|
3276
|
+
frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
|
|
3277
|
+
};
|
|
3278
|
+
}
|
|
3279
|
+
/** Real `runPipeline` dep: node-local `pipelineExecutor.runPipeline` on the `'full'` plane. */
|
|
3280
|
+
async runDetailPipeline(steps, imageJpeg, deviceId) {
|
|
3281
|
+
const api = this.ctxIfReady?.api;
|
|
3282
|
+
if (!api) return null;
|
|
3283
|
+
try {
|
|
3284
|
+
return await api.pipelineExecutor.runPipeline.mutate({
|
|
3285
|
+
steps: [...steps],
|
|
3286
|
+
image: new Uint8Array(imageJpeg),
|
|
3287
|
+
deviceId,
|
|
3288
|
+
plane: "full"
|
|
3289
|
+
});
|
|
3290
|
+
} catch (err) {
|
|
3291
|
+
this.ctxIfReady?.logger.warn("runDetailSubtree: runPipeline failed", {
|
|
3292
|
+
tags: { deviceId },
|
|
3293
|
+
meta: { error: require_dist.errMsg(err) }
|
|
3294
|
+
});
|
|
3295
|
+
return null;
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
2782
3298
|
detachInternal(deviceId) {
|
|
2783
3299
|
const attachment = this.attached.get(deviceId);
|
|
2784
3300
|
if (!attachment) return;
|
|
@@ -2789,6 +3305,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
2789
3305
|
}
|
|
2790
3306
|
attachment.motionUnsubscribe?.();
|
|
2791
3307
|
attachment.detectionUnsubscribe?.();
|
|
3308
|
+
this.lastMotionFrameAt.delete(deviceId);
|
|
2792
3309
|
const remotePlane = this.remotePlanes.get(deviceId);
|
|
2793
3310
|
if (remotePlane) {
|
|
2794
3311
|
this.remotePlanes.delete(deviceId);
|
|
@@ -3321,7 +3838,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
3321
3838
|
return await api.pipelineExecutor.runPipeline.mutate({
|
|
3322
3839
|
steps: [...steps],
|
|
3323
3840
|
...useHandle ? { frameHandle: handle } : { frame },
|
|
3324
|
-
deviceId
|
|
3841
|
+
deviceId,
|
|
3842
|
+
plane: "frame"
|
|
3325
3843
|
});
|
|
3326
3844
|
} catch (err) {
|
|
3327
3845
|
const msg = require_dist.errMsg(err);
|
|
@@ -3333,6 +3851,8 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
3333
3851
|
const ctx = this.ctx;
|
|
3334
3852
|
const runner = this.runner;
|
|
3335
3853
|
if (!ctx || !runner) return;
|
|
3854
|
+
this.lastMotionFrameAt.set(deviceId, Date.now());
|
|
3855
|
+
this.teeOccupancyTapFrame(deviceId, frame, handle);
|
|
3336
3856
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
3337
3857
|
const motionStart = Date.now();
|
|
3338
3858
|
try {
|
|
@@ -3447,11 +3967,98 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
3447
3967
|
});
|
|
3448
3968
|
}
|
|
3449
3969
|
}
|
|
3970
|
+
/**
|
|
3971
|
+
* Shared burst-completion path (Task 9 report + Task 10 dial/tap
|
|
3972
|
+
* consolidation): clears the in-flight flag, reads the `found` flag the
|
|
3973
|
+
* `onResult` handler set while the burst was live, reports it to the
|
|
3974
|
+
* runner's arm/disarm state machine, and logs. Both the dial path
|
|
3975
|
+
* (`runOccupancyBurst`'s `cleanup`) and the tap path
|
|
3976
|
+
* (`finishOccupancyTap`) fold into this single implementation so the
|
|
3977
|
+
* found/report semantics can't drift between the two admission
|
|
3978
|
+
* mechanisms.
|
|
3979
|
+
*/
|
|
3980
|
+
finishOccupancyBurst(deviceId, log, collected) {
|
|
3981
|
+
this.occupancyBurstInFlight.delete(deviceId);
|
|
3982
|
+
const found = this.occupancyBurstFound.has(deviceId);
|
|
3983
|
+
this.occupancyBurstFound.delete(deviceId);
|
|
3984
|
+
this.runner?.reportOccupancyBurstOutcome(deviceId, found);
|
|
3985
|
+
log.debug("occupancy re-check: burst complete", { meta: {
|
|
3986
|
+
collected,
|
|
3987
|
+
found
|
|
3988
|
+
} });
|
|
3989
|
+
}
|
|
3990
|
+
/**
|
|
3991
|
+
* Task 10: arm a TAP-based occupancy burst for a device whose analyzer
|
|
3992
|
+
* motion subscription is already flowing (`attachment.motionUnsubscribe
|
|
3993
|
+
* !== null` — see `runOccupancyBurst`). Frames feed in via
|
|
3994
|
+
* `teeOccupancyTapFrame`, called from `runMotionAnalysis` on every
|
|
3995
|
+
* arriving substream frame; NO new main-stream poller/decode session is
|
|
3996
|
+
* started. A safety timer (mirrors the dial path's) completes the burst
|
|
3997
|
+
* even if the motion subscription stops producing frames mid-burst.
|
|
3998
|
+
*
|
|
3999
|
+
* Returns a promise that resolves once the tap settles (budget drained or
|
|
4000
|
+
* safety timeout) — `runOccupancyBurst` awaits it so the global
|
|
4001
|
+
* `occupancyActiveCount` slot stays held for the tap's full duration, the
|
|
4002
|
+
* same as the dial path.
|
|
4003
|
+
*/
|
|
4004
|
+
/**
|
|
4005
|
+
* Returns a promise that resolves with the tap's final `collected` count
|
|
4006
|
+
* once it settles (budget drained or safety timeout). The caller
|
|
4007
|
+
* (`runOccupancyBurst`) decides what to do with that count — report the
|
|
4008
|
+
* outcome via `finishOccupancyBurst` when frames were actually collected,
|
|
4009
|
+
* or fall back to a dial burst when the tap collected zero (Task 10
|
|
4010
|
+
* hardening #2). Resolving with the count rather than reporting from
|
|
4011
|
+
* inside the tap itself is what makes that fallback possible without
|
|
4012
|
+
* double-reporting.
|
|
4013
|
+
*/
|
|
4014
|
+
startOccupancyTap(deviceId, frames, log) {
|
|
4015
|
+
this.occupancyBurstInFlight.add(deviceId);
|
|
4016
|
+
this.occupancyBurstFound.delete(deviceId);
|
|
4017
|
+
log.debug("occupancy re-check: tapping live analyzer motion frames (no dial)", { meta: { frames } });
|
|
4018
|
+
return new Promise((resolve) => {
|
|
4019
|
+
const safetyTimer = setTimeout(() => {
|
|
4020
|
+
const collected = this.occupancyTaps.get(deviceId)?.collected ?? 0;
|
|
4021
|
+
log.debug("occupancy re-check: tap safety timeout reached", { meta: { collected } });
|
|
4022
|
+
this.finishOccupancyTap(deviceId);
|
|
4023
|
+
}, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
|
|
4024
|
+
this.occupancyTaps.set(deviceId, {
|
|
4025
|
+
remaining: frames,
|
|
4026
|
+
collected: 0,
|
|
4027
|
+
safetyTimer,
|
|
4028
|
+
resolve
|
|
4029
|
+
});
|
|
4030
|
+
});
|
|
4031
|
+
}
|
|
4032
|
+
/** Idempotent: a no-op once the tap has already settled (timer fired, or the budget already drained). */
|
|
4033
|
+
finishOccupancyTap(deviceId) {
|
|
4034
|
+
const tap = this.occupancyTaps.get(deviceId);
|
|
4035
|
+
if (!tap) return;
|
|
4036
|
+
clearTimeout(tap.safetyTimer);
|
|
4037
|
+
this.occupancyTaps.delete(deviceId);
|
|
4038
|
+
tap.resolve(tap.collected);
|
|
4039
|
+
}
|
|
4040
|
+
/**
|
|
4041
|
+
* Tee an arriving analyzer motion frame into occupancy-burst detection
|
|
4042
|
+
* admission (Task 10). Called from `runMotionAnalysis` on EVERY
|
|
4043
|
+
* substream frame; a no-op unless `startOccupancyTap` armed a budget for
|
|
4044
|
+
* this device. The frame always feeds `enqueueOccupancyFrame` — the
|
|
4045
|
+
* handle may be `undefined` (e.g. the Epic-C per-session motion decode
|
|
4046
|
+
* worker never produces a shm handle) and `runInference` already falls
|
|
4047
|
+
* back to the pixel path in that case, so every arriving frame counts
|
|
4048
|
+
* toward the budget regardless of whether it carried a zero-copy handle.
|
|
4049
|
+
*/
|
|
4050
|
+
teeOccupancyTapFrame(deviceId, frame, handle) {
|
|
4051
|
+
const tap = this.occupancyTaps.get(deviceId);
|
|
4052
|
+
if (!tap) return;
|
|
4053
|
+
this.runner?.enqueueOccupancyFrame(deviceId, frame, handle);
|
|
4054
|
+
tap.collected++;
|
|
4055
|
+
tap.remaining--;
|
|
4056
|
+
if (tap.remaining <= 0) this.finishOccupancyTap(deviceId);
|
|
4057
|
+
}
|
|
3450
4058
|
async runOccupancyBurst(deviceId, frames) {
|
|
3451
4059
|
if (this.occupancyBurstInFlight.has(deviceId)) return;
|
|
3452
4060
|
const attachment = this.attached.get(deviceId);
|
|
3453
4061
|
if (!attachment) return;
|
|
3454
|
-
const config = attachment.config;
|
|
3455
4062
|
const runner = this.runner;
|
|
3456
4063
|
if (!runner) return;
|
|
3457
4064
|
const log = this.ctx.logger.withTags({ deviceId });
|
|
@@ -3460,8 +4067,40 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
3460
4067
|
log.debug("runOccupancyBurst: ctx.api not available");
|
|
3461
4068
|
return;
|
|
3462
4069
|
}
|
|
3463
|
-
this.
|
|
3464
|
-
|
|
4070
|
+
const lastFrameAt = this.lastMotionFrameAt.get(deviceId);
|
|
4071
|
+
const motionFresh = lastFrameAt !== void 0 && Date.now() - lastFrameAt < MOTION_FRESHNESS_WINDOW_MS;
|
|
4072
|
+
if (attachment.motionUnsubscribe !== null && motionFresh) {
|
|
4073
|
+
const collected = await this.startOccupancyTap(deviceId, frames, log);
|
|
4074
|
+
if (collected > 0) {
|
|
4075
|
+
this.finishOccupancyBurst(deviceId, log, collected);
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
log.debug("occupancy re-check: tap collected 0 frames, falling back to dial", { meta: { frames } });
|
|
4079
|
+
await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, true);
|
|
4080
|
+
return;
|
|
4081
|
+
}
|
|
4082
|
+
await this.runOccupancyDial(deviceId, frames, attachment, runner, api, log, false);
|
|
4083
|
+
}
|
|
4084
|
+
/**
|
|
4085
|
+
* Dial-path occupancy burst: subscribes a fresh main-stream decode session
|
|
4086
|
+
* via `startFrameHandlePoller` and collects `frames` handles (or times out
|
|
4087
|
+
* at {@link OCCUPANCY_BURST_SAFETY_TIMEOUT_MS}). Called either directly
|
|
4088
|
+
* from `runOccupancyBurst` (no live/fresh motion tap available,
|
|
4089
|
+
* `alreadyInFlight: false`) or as the Task 10 hardening #2 fallback after
|
|
4090
|
+
* a zero-collected tap (`alreadyInFlight: true` — the tap already claimed
|
|
4091
|
+
* the `occupancyBurstInFlight`/`occupancyBurstFound` slot, so this must
|
|
4092
|
+
* NOT re-add it).
|
|
4093
|
+
*/
|
|
4094
|
+
async runOccupancyDial(deviceId, frames, attachment, runner, api, log, alreadyInFlight) {
|
|
4095
|
+
const config = attachment.config;
|
|
4096
|
+
if (!alreadyInFlight) {
|
|
4097
|
+
this.occupancyBurstInFlight.add(deviceId);
|
|
4098
|
+
this.occupancyBurstFound.delete(deviceId);
|
|
4099
|
+
}
|
|
4100
|
+
log.debug("occupancy re-check: starting burst", { meta: {
|
|
4101
|
+
frames,
|
|
4102
|
+
fallback: alreadyInFlight
|
|
4103
|
+
} });
|
|
3465
4104
|
let count = 0;
|
|
3466
4105
|
let unsubscribe = null;
|
|
3467
4106
|
let unsubbed = false;
|
|
@@ -3476,13 +4115,12 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
3476
4115
|
if (settled) return;
|
|
3477
4116
|
settled = true;
|
|
3478
4117
|
doUnsub();
|
|
3479
|
-
this.
|
|
3480
|
-
log.debug("occupancy re-check: burst complete", { meta: { collected: count } });
|
|
4118
|
+
this.finishOccupancyBurst(deviceId, log, count);
|
|
3481
4119
|
};
|
|
3482
4120
|
const safetyTimer = setTimeout(() => {
|
|
3483
4121
|
log.debug("occupancy re-check: safety timeout reached", { meta: { collected: count } });
|
|
3484
4122
|
cleanup();
|
|
3485
|
-
},
|
|
4123
|
+
}, OCCUPANCY_BURST_SAFETY_TIMEOUT_MS);
|
|
3486
4124
|
try {
|
|
3487
4125
|
unsubscribe = await startFrameHandlePoller({
|
|
3488
4126
|
api,
|
|
@@ -3506,6 +4144,7 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
3506
4144
|
} catch (err) {
|
|
3507
4145
|
clearTimeout(safetyTimer);
|
|
3508
4146
|
this.occupancyBurstInFlight.delete(deviceId);
|
|
4147
|
+
this.occupancyBurstFound.delete(deviceId);
|
|
3509
4148
|
log.debug("occupancy re-check: poller start failed", { meta: { error: require_dist.errMsg(err) } });
|
|
3510
4149
|
return;
|
|
3511
4150
|
}
|
|
@@ -3517,12 +4156,14 @@ var PipelineRunnerAddon = class extends require_dist.BaseAddon {
|
|
|
3517
4156
|
emitInferenceResult(deviceId, frame, result, handle) {
|
|
3518
4157
|
if (!this.ctx?.eventBus) return;
|
|
3519
4158
|
const capturedAt = frame.capturedAt;
|
|
4159
|
+
const detailSteps = this.attached.get(deviceId)?.detailSteps;
|
|
3520
4160
|
const payload = {
|
|
3521
4161
|
deviceId,
|
|
3522
4162
|
frame: result,
|
|
3523
4163
|
nodeId: this.nodeId,
|
|
3524
4164
|
frameHandle: handle,
|
|
3525
|
-
...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {}
|
|
4165
|
+
...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {},
|
|
4166
|
+
...detailSteps && detailSteps.length > 0 ? { detailSteps } : {}
|
|
3526
4167
|
};
|
|
3527
4168
|
this.ctx.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.PipelineInferenceResult, {
|
|
3528
4169
|
type: "device",
|
|
@@ -3687,6 +4328,7 @@ exports.Semaphore = Semaphore;
|
|
|
3687
4328
|
exports.acquireSessionDecodeRestreamFrom = acquireSessionDecodeRestreamFrom;
|
|
3688
4329
|
exports.customActions = pipelineRunnerBenchActions;
|
|
3689
4330
|
exports.default = PipelineRunnerAddon;
|
|
4331
|
+
exports.deriveDetailSteps = deriveDetailSteps;
|
|
3690
4332
|
exports.resolveMotionFrameSource = resolveMotionFrameSource;
|
|
3691
4333
|
exports.resolveSessionDecodeHostname = resolveSessionDecodeHostname;
|
|
3692
4334
|
exports.resolveSessionDecodeOwnerNodeId = resolveSessionDecodeOwnerNodeId;
|