@trillboards/edge-sdk 0.2.13 → 0.2.15
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/deploy/windows/README.md +7 -0
- package/deploy/windows/build-windows.ps1 +4 -0
- package/deploy/windows/ensure-sensing.ps1 +58 -0
- package/deploy/windows/install-update.ps1 +32 -6
- package/deploy/windows/setup-litertlm.ps1 +45 -0
- package/deploy/windows/stop-existing.ps1 +51 -1
- package/deploy/windows/trillboards-edge.cmd +8 -2
- package/deploy/windows/trillboards-hidden-run.vbs +47 -0
- package/deploy/windows/trillboards-setup.ps1 +25 -0
- package/dist/AudienceAnalyzeClient.d.ts +16 -2
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
- package/dist/AudienceAnalyzeClient.js.map +1 -1
- package/dist/CloudSensingManager.d.ts +213 -5
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +432 -23
- package/dist/CloudSensingManager.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/SceneChangeDetector.d.ts +39 -3
- package/dist/SceneChangeDetector.d.ts.map +1 -1
- package/dist/SceneChangeDetector.js +84 -16
- package/dist/SceneChangeDetector.js.map +1 -1
- package/dist/VlmAdmissionGate.d.ts +14 -0
- package/dist/VlmAdmissionGate.d.ts.map +1 -1
- package/dist/VlmAdmissionGate.js +14 -0
- package/dist/VlmAdmissionGate.js.map +1 -1
- package/dist/VlmCandidateBuffer.d.ts +236 -0
- package/dist/VlmCandidateBuffer.d.ts.map +1 -0
- package/dist/VlmCandidateBuffer.js +347 -0
- package/dist/VlmCandidateBuffer.js.map +1 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/discovery.d.ts +15 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +65 -0
- package/dist/discovery.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/recorderRanking.d.ts +71 -0
- package/dist/recorderRanking.d.ts.map +1 -0
- package/dist/recorderRanking.js +230 -0
- package/dist/recorderRanking.js.map +1 -0
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +124 -0
- package/dist/sensingRunMode.js.map +1 -1
- package/dist/setupMode.d.ts +9 -0
- package/dist/setupMode.d.ts.map +1 -1
- package/dist/setupMode.js +23 -1
- package/dist/setupMode.js.map +1 -1
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ const runtimePaths_1 = require("./runtimePaths");
|
|
|
12
12
|
const legacyRecorderProfile_1 = require("./legacyRecorderProfile");
|
|
13
13
|
const VlmAdmissionGate_1 = require("./VlmAdmissionGate");
|
|
14
14
|
const SceneChangeDetector_1 = require("./SceneChangeDetector");
|
|
15
|
+
const VlmCandidateBuffer_1 = require("./VlmCandidateBuffer");
|
|
15
16
|
/**
|
|
16
17
|
* Thin cloud-sensing run mode.
|
|
17
18
|
*
|
|
@@ -32,11 +33,11 @@ const SceneChangeDetector_1 = require("./SceneChangeDetector");
|
|
|
32
33
|
* (`litert-lm serve`) takes one conversation at a time. So a tick is not "grab
|
|
33
34
|
* a frame and send it"; it is a two-stage cascade, cheapest stage first:
|
|
34
35
|
*
|
|
35
|
-
* 1. ADMISSION (`VlmAdmissionGate`, global, one slot).
|
|
36
|
-
* inference
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* 1. ADMISSION (`VlmAdmissionGate`, global, one slot). Denied ticks DROP
|
|
37
|
+
* inference work and re-arm at a short retry delay. A separate, serialized
|
|
38
|
+
* decode-only probe runs at a much slower cadence so model saturation never
|
|
39
|
+
* makes healthy video look disconnected; that probe is not encoded,
|
|
40
|
+
* queued, or analyzed.
|
|
40
41
|
* 2. DECIMATION (`motionGate.scanIntervalMs`, floored at
|
|
41
42
|
* `DEFAULT_MIN_MOTION_SCAN_INTERVAL_MS`). Frigate's stage 1: never LOOK
|
|
42
43
|
* faster than 5 fps, because motion does not get more truthful above that
|
|
@@ -49,6 +50,36 @@ const SceneChangeDetector_1 = require("./SceneChangeDetector");
|
|
|
49
50
|
* the same cells as the last analyzed frame is a parked car or a swinging
|
|
50
51
|
* sign, and costs 1/N of a moving one.
|
|
51
52
|
*
|
|
53
|
+
* ── Where admission sits, and the candidate buffer ──────────────────────────
|
|
54
|
+
* With the cascade OFF, admission is checked FIRST: nothing has classified the
|
|
55
|
+
* frame, so there is no reason to pay for a grab the model cannot take, and a
|
|
56
|
+
* denied tick drops without touching the camera. That is the original order and
|
|
57
|
+
* it is unchanged.
|
|
58
|
+
*
|
|
59
|
+
* With the cascade ON, the order inverts — LOOK first, admit second — and a
|
|
60
|
+
* candidate the gate cannot take right now is HELD in `VlmCandidateBuffer`
|
|
61
|
+
* rather than dropped. Both halves of that are load-bearing:
|
|
62
|
+
*
|
|
63
|
+
* - Admission-first meant a saturated connector never looked. The motion
|
|
64
|
+
* stage only runs on a frame we grabbed, so while the model was busy no
|
|
65
|
+
* feed could even form an opinion, and "the cascade already decided this
|
|
66
|
+
* frame is interesting" was unreachable exactly when it mattered. Measured
|
|
67
|
+
* in the field on 32 cameras (connector 0.2.13): ~2.4 analyses/min, so a
|
|
68
|
+
* given camera waits ~13 minutes for its turn — longer than any spill,
|
|
69
|
+
* fall or restricted-area entry lasts.
|
|
70
|
+
* - Holding is bounded and only for CANDIDATES. A no-change frame is never
|
|
71
|
+
* held; that would rebuild the undifferentiated backlog the gate refuses.
|
|
72
|
+
* See VlmCandidateBuffer for the three bounds, the freshness TTL, and why
|
|
73
|
+
* "a stale frame is worthless" is true of ambient sampling and false of
|
|
74
|
+
* alerting.
|
|
75
|
+
*
|
|
76
|
+
* Looking while the model is busy is affordable because looking is the cheap
|
|
77
|
+
* half by construction (stage 1 floors it at 5 fps per feed, and a real
|
|
78
|
+
* deployment runs ~150x slower than that), and because the denied path ALREADY
|
|
79
|
+
* pulled a decode-only health frame and threw the information away. The
|
|
80
|
+
* inversion also narrows what an admission slot means: it is now held for the
|
|
81
|
+
* inference alone, not for the grab and encode in front of it.
|
|
82
|
+
*
|
|
52
83
|
* Every stage is independently disableable, and every drop is attributed to the
|
|
53
84
|
* stage that made it in `getSensingHealth().cascade.byReason` — because the
|
|
54
85
|
* honest form of degradation on a machine that cannot keep up is a number an
|
|
@@ -190,6 +221,7 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
190
221
|
/** The last cadence verdict applied, surfaced by getSensingHealth(). */
|
|
191
222
|
this.cadence = null;
|
|
192
223
|
this.cadenceTimer = null;
|
|
224
|
+
this.candidatePumpTimer = null;
|
|
193
225
|
/** Retained so a cadence change can rebuild each feed's gate heartbeat. */
|
|
194
226
|
this.motionGateOptions = null;
|
|
195
227
|
/** Look-rate for a still feed when the cascade is on. null ⇒ cascade off. */
|
|
@@ -278,6 +310,14 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
278
310
|
});
|
|
279
311
|
}
|
|
280
312
|
}
|
|
313
|
+
// The hold exists only where a cascade can name a candidate. `enabled`
|
|
314
|
+
// can turn it OFF where it would otherwise run; it can never turn it on
|
|
315
|
+
// without a classifier, because that is the queue the gate refuses.
|
|
316
|
+
const candidateBufferEnabled = motionGateEnabled && options.candidateBuffer?.enabled !== false;
|
|
317
|
+
this.candidates = candidateBufferEnabled
|
|
318
|
+
? new VlmCandidateBuffer_1.VlmCandidateBuffer({ ...options.candidateBuffer, now: this.now })
|
|
319
|
+
: null;
|
|
320
|
+
this.candidatePumpIntervalMs = Math.max(1, Math.trunc(options.candidateBuffer?.pumpIntervalMs ?? this.admissionRetryDelayMs));
|
|
281
321
|
this.motionDrivesCadence = options.motionDrivesCadence ?? motionGateEnabled;
|
|
282
322
|
this.cadenceSource = options.cadenceSource;
|
|
283
323
|
this.cadenceRefreshIntervalMs = Math.max(1000, Math.trunc(options.cadenceRefreshIntervalMs ?? DEFAULT_CADENCE_REFRESH_MS));
|
|
@@ -409,6 +449,10 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
409
449
|
const cloudEscalated = escalation.reduce((n, e) => n + e.escalated, 0);
|
|
410
450
|
// Per-stage attribution: read each feed's gate once, then fold.
|
|
411
451
|
const gateStats = this.loops.map((s) => s.motionGate?.getStats() ?? null);
|
|
452
|
+
// One read of the hold, so the top-level totals and the per-feed rows can
|
|
453
|
+
// never describe two different instants.
|
|
454
|
+
const candidateStats = this.candidates?.getStats() ?? null;
|
|
455
|
+
const candidateByFeed = new Map((candidateStats?.perFeed ?? []).map((f) => [f.name, f]));
|
|
412
456
|
const cascadeByReason = emptyMotionReasonCounts();
|
|
413
457
|
let cascadeEvaluated = 0;
|
|
414
458
|
let cascadeAnalyzed = 0;
|
|
@@ -447,8 +491,24 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
447
491
|
skipped: cascadeEvaluated - cascadeAnalyzed,
|
|
448
492
|
byReason: cascadeByReason,
|
|
449
493
|
},
|
|
494
|
+
candidateBuffer: {
|
|
495
|
+
enabled: this.candidates !== null,
|
|
496
|
+
buffered: candidateStats?.buffered ?? 0,
|
|
497
|
+
bytes: candidateStats?.bytes ?? 0,
|
|
498
|
+
maxFrames: candidateStats?.maxFrames ?? 0,
|
|
499
|
+
maxPerFeed: candidateStats?.maxPerFeed ?? 0,
|
|
500
|
+
maxBytes: candidateStats?.maxBytes ?? 0,
|
|
501
|
+
ttlMs: candidateStats?.ttlMs ?? 0,
|
|
502
|
+
enqueued: candidateStats?.enqueued ?? 0,
|
|
503
|
+
served: candidateStats?.served ?? 0,
|
|
504
|
+
evicted: candidateStats?.evicted ?? 0,
|
|
505
|
+
expired: candidateStats?.expired ?? 0,
|
|
506
|
+
oldestAgeMs: candidateStats?.oldestAgeMs ?? null,
|
|
507
|
+
perFeed: candidateStats?.perFeed ?? [],
|
|
508
|
+
},
|
|
450
509
|
cloudEscalated,
|
|
451
510
|
cloudEscalationEnabled: escalation.some((e) => e.enabled),
|
|
511
|
+
accelerator: foldAcceleratorHealth(this.loops.map((s) => acceleratorHealthFor(s)).filter((a) => a !== null)),
|
|
452
512
|
perFeed: this.loops.map((s, i) => ({
|
|
453
513
|
name: s.deps.name,
|
|
454
514
|
captures: s.captureCount,
|
|
@@ -458,6 +518,8 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
458
518
|
motionSkippedByReason: gateStats[i]
|
|
459
519
|
? { ...emptyMotionReasonCounts(), ...gateStats[i].byReason }
|
|
460
520
|
: emptyMotionReasonCounts(),
|
|
521
|
+
candidatesBuffered: candidateByFeed.get(s.deps.name)?.buffered ?? 0,
|
|
522
|
+
candidatesServed: candidateByFeed.get(s.deps.name)?.served ?? 0,
|
|
461
523
|
cloudEscalated: escalation[i].escalated,
|
|
462
524
|
faceDetected: s.faceDetected,
|
|
463
525
|
currentIntervalMs: this.intervalFor(s),
|
|
@@ -485,6 +547,11 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
485
547
|
s.runMotionSkipped = 0;
|
|
486
548
|
void this.startLoop(s, generation).catch((err) => this.emitLoopError(s, err));
|
|
487
549
|
}
|
|
550
|
+
// A candidate held across a stop()/start() cycle is a frame from a run
|
|
551
|
+
// whose cameras may now be pointing somewhere else — the same reason the
|
|
552
|
+
// motion gate re-seeds above.
|
|
553
|
+
this.candidates?.reset();
|
|
554
|
+
this.scheduleCandidatePump(generation);
|
|
488
555
|
// Ask once at startup so a connector picks up its deployed cadence on boot
|
|
489
556
|
// rather than after a full refresh period, then keep asking. Both are
|
|
490
557
|
// best-effort: refreshDeployedCadence never throws and never resets the
|
|
@@ -508,6 +575,12 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
508
575
|
// closed against the NEXT run.
|
|
509
576
|
this.admissionGate.reset();
|
|
510
577
|
this.healthCaptureInFlight = false;
|
|
578
|
+
if (this.candidatePumpTimer) {
|
|
579
|
+
clearTimeout(this.candidatePumpTimer);
|
|
580
|
+
this.candidatePumpTimer = null;
|
|
581
|
+
}
|
|
582
|
+
// Held frames belong to the run that captured them.
|
|
583
|
+
this.candidates?.reset();
|
|
511
584
|
if (this.cadenceTimer) {
|
|
512
585
|
clearInterval(this.cadenceTimer);
|
|
513
586
|
this.cadenceTimer = null;
|
|
@@ -622,7 +695,9 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
622
695
|
}
|
|
623
696
|
return;
|
|
624
697
|
}
|
|
625
|
-
|
|
698
|
+
// The handshake succeeded — that is REACHABILITY, not video. The first
|
|
699
|
+
// real capture below is what proves frames decode.
|
|
700
|
+
this.reportCameraOpen(s, { streamOpened: true, sensingState: 'idle' });
|
|
626
701
|
}
|
|
627
702
|
catch (err) {
|
|
628
703
|
if (!this.isCurrentStart(generation)) {
|
|
@@ -827,7 +902,11 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
827
902
|
// frame as "changed". The heartbeat clock is deliberately NOT reset: an
|
|
828
903
|
// outage must not delay the feed's next guaranteed report.
|
|
829
904
|
s.motionGate?.reset();
|
|
830
|
-
|
|
905
|
+
// A re-open proves the recorder answered again — NOT that video came
|
|
906
|
+
// back. Claiming a frame here let a flapping feed refresh its own video
|
|
907
|
+
// clock forever, so a camera that reconnected every few seconds and
|
|
908
|
+
// decoded nothing could never age into "waiting for video".
|
|
909
|
+
this.reportCameraOpen(s, { streamOpened: true, sensingState: 'idle' });
|
|
831
910
|
this.emit('reconnected', { name: s.deps.name });
|
|
832
911
|
return true;
|
|
833
912
|
}
|
|
@@ -886,14 +965,35 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
886
965
|
});
|
|
887
966
|
});
|
|
888
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* Report this feed as `open`, carrying the proof we ACTUALLY have.
|
|
970
|
+
*
|
|
971
|
+
* `frameProven` and `streamOpened` are two different facts and the caller must
|
|
972
|
+
* not conflate them:
|
|
973
|
+
*
|
|
974
|
+
* `streamOpened` — `camera.open()` resolved. The RTSP handshake completed,
|
|
975
|
+
* so the RECORDER IS REACHABLE. No frame has decoded, so
|
|
976
|
+
* this deliberately carries NO `lastFrameAtMs` and does NOT
|
|
977
|
+
* advance `lastCaptureAtMs` — a feed that has proved only
|
|
978
|
+
* reachability is still owed a video probe, immediately.
|
|
979
|
+
* `frameProven` — ffmpeg handed us real bytes. VIDEO IS HEALTHY.
|
|
980
|
+
*
|
|
981
|
+
* Both force a report, because both are transitions an operator is waiting on
|
|
982
|
+
* ("Connecting…" must clear the moment the recorder answers).
|
|
983
|
+
*/
|
|
889
984
|
reportCameraOpen(s, options = {}) {
|
|
890
985
|
const sensingState = options.sensingState ?? s.lastSensingState ?? 'idle';
|
|
891
986
|
const now = this.now();
|
|
892
987
|
const stateChanged = s.lastSensingState !== sensingState;
|
|
893
988
|
const heartbeatDue = s.lastHealthReportAtMs == null ||
|
|
894
989
|
now - s.lastHealthReportAtMs >= this.cameraHealthHeartbeatIntervalMs;
|
|
895
|
-
if (!options.frameProven &&
|
|
990
|
+
if (!options.frameProven &&
|
|
991
|
+
!options.streamOpened &&
|
|
992
|
+
!options.force &&
|
|
993
|
+
!stateChanged &&
|
|
994
|
+
!heartbeatDue) {
|
|
896
995
|
return;
|
|
996
|
+
}
|
|
897
997
|
if (options.frameProven)
|
|
898
998
|
s.lastCaptureAtMs = now;
|
|
899
999
|
this.reportCameraHealth(s, {
|
|
@@ -902,7 +1002,9 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
902
1002
|
sensingState,
|
|
903
1003
|
...(options.frameProven
|
|
904
1004
|
? { lastFrameAtMs: now, captureProof: 'decoded_frame' }
|
|
905
|
-
:
|
|
1005
|
+
: options.streamOpened
|
|
1006
|
+
? { captureProof: 'stream_open' }
|
|
1007
|
+
: {}),
|
|
906
1008
|
});
|
|
907
1009
|
}
|
|
908
1010
|
healthProbeDue(s) {
|
|
@@ -934,17 +1036,127 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
934
1036
|
}
|
|
935
1037
|
}
|
|
936
1038
|
/**
|
|
937
|
-
* One cascade pass for a feed
|
|
938
|
-
*
|
|
1039
|
+
* One cascade pass for a feed. Which pass depends on whether there is a
|
|
1040
|
+
* cascade to name a candidate — see the class header for why the order
|
|
1041
|
+
* differs and why that is not two behaviours bolted together.
|
|
939
1042
|
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
1043
|
+
* Deliberately NOT `async`: it hands the chosen pass's promise straight back
|
|
1044
|
+
* instead of adopting it. An `async` dispatcher would insert two microtask
|
|
1045
|
+
* hops in front of every capture on a path this PR does not otherwise
|
|
1046
|
+
* change, and a fake-timer test that advances a fixed number of drains would
|
|
1047
|
+
* then complete fewer ticks per advance — which is not a behaviour change but
|
|
1048
|
+
* is indistinguishable from one at 2 a.m. Zero delta on the untouched path is
|
|
1049
|
+
* cheaper to hold than the argument about whether the delta mattered.
|
|
943
1050
|
*/
|
|
944
|
-
|
|
1051
|
+
captureAndPost(s) {
|
|
945
1052
|
if (!s.deps.camera.isOpen()) {
|
|
946
|
-
|
|
1053
|
+
return Promise.reject(new Error(`CloudSensingManager: camera "${s.deps.name}" is not open`));
|
|
1054
|
+
}
|
|
1055
|
+
if (this.candidates && s.motionGate) {
|
|
1056
|
+
return this.lookThenAdmit(s, this.candidates, s.motionGate);
|
|
1057
|
+
}
|
|
1058
|
+
return this.admitThenLook(s);
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* LOOK FIRST, admit second: capture → motion → (candidate) → admission.
|
|
1062
|
+
*
|
|
1063
|
+
* Runs only with the cascade on, because only the cascade can answer "is
|
|
1064
|
+
* this frame worth holding?". A candidate the gate cannot take right now
|
|
1065
|
+
* goes into the bounded hold instead of onto the floor; anything the cascade
|
|
1066
|
+
* rejected is dropped exactly as before and is never offered.
|
|
1067
|
+
*/
|
|
1068
|
+
async lookThenAdmit(s, candidates, motionGate) {
|
|
1069
|
+
const frame = await s.deps.camera.getFrame();
|
|
1070
|
+
const res = safeGetResolution(s.deps.camera);
|
|
1071
|
+
const jpeg = await this.toJpeg(frame, res?.width, res?.height);
|
|
1072
|
+
s.captureCount++;
|
|
1073
|
+
this.reportCameraOpen(s, { frameProven: true, sensingState: 'analyzing', force: true });
|
|
1074
|
+
this.emit('frameCaptured', { name: s.deps.name, faceDetected: s.faceDetected });
|
|
1075
|
+
const verdict = await motionGate.evaluate(jpeg);
|
|
1076
|
+
if (this.motionDrivesCadence) {
|
|
1077
|
+
this.applyMotionCadence(s, verdict.motion);
|
|
1078
|
+
}
|
|
1079
|
+
if (!verdict.analyze) {
|
|
1080
|
+
s.motionSkipCount++;
|
|
1081
|
+
s.runMotionSkipped++;
|
|
1082
|
+
this.emit('motionSkipped', {
|
|
1083
|
+
name: s.deps.name,
|
|
1084
|
+
reason: verdict.reason,
|
|
1085
|
+
pctMotion: verdict.pctMotion,
|
|
1086
|
+
regions: verdict.boxes.length,
|
|
1087
|
+
});
|
|
1088
|
+
this.reportCameraOpen(s, { sensingState: 'idle' });
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
// The cascade says this frame is worth an inference. Everything below is
|
|
1092
|
+
// about WHEN it gets one, never about whether it survives.
|
|
1093
|
+
const captureMode = s.faceDetected ? 'face_triggered' : 'periodic';
|
|
1094
|
+
const ticket = this.admissionGate.tryAcquire(s.deps.name);
|
|
1095
|
+
if (!ticket) {
|
|
1096
|
+
s.admissionDeniedCount++;
|
|
1097
|
+
s.runAdmissionDenied++;
|
|
1098
|
+
// Only a CHANGE is held. A still-scene heartbeat or an undecodable frame
|
|
1099
|
+
// is a sample, and for a sample the admission gate's original rule is
|
|
1100
|
+
// exactly right: the newest one is the best one, so holding an old one
|
|
1101
|
+
// would spend a slot a real event could have had. Measured at 32 cameras
|
|
1102
|
+
// on the CPU profile, that distinction is not cosmetic — starvation makes
|
|
1103
|
+
// almost every feed permanently heartbeat-due, so a hold that took every
|
|
1104
|
+
// heartbeat filled with proof-of-life frames and crowded the arrivals out
|
|
1105
|
+
// of their own buffer.
|
|
1106
|
+
//
|
|
1107
|
+
// `isChangeCandidate` reads the whole verdict and not its label, because
|
|
1108
|
+
// that same starvation is what makes a REAL arrival arrive labelled
|
|
1109
|
+
// `heartbeat` — the gate resolves heartbeat-due before it looks at the
|
|
1110
|
+
// motion verdict, so the change survives only in `verdict.motion`.
|
|
1111
|
+
const buffered = (0, SceneChangeDetector_1.isChangeCandidate)(verdict) &&
|
|
1112
|
+
candidates.offer({
|
|
1113
|
+
name: s.deps.name,
|
|
1114
|
+
jpeg,
|
|
1115
|
+
captureMode,
|
|
1116
|
+
reason: verdict.reason,
|
|
1117
|
+
signature: verdict.signature,
|
|
1118
|
+
}) === 'buffered';
|
|
1119
|
+
this.emit('admissionDenied', {
|
|
1120
|
+
name: s.deps.name,
|
|
1121
|
+
// No short retry either way. Unlike the no-cascade path, this tick has
|
|
1122
|
+
// already LOOKED: a held frame is safe until the pump takes it, and a
|
|
1123
|
+
// frame we chose not to hold is one whose successor is strictly
|
|
1124
|
+
// better — so the right next move is the normal scan cadence, not a
|
|
1125
|
+
// 250 ms re-grab of the same scene on all 32 feeds at once.
|
|
1126
|
+
retryInMs: null,
|
|
1127
|
+
buffered,
|
|
1128
|
+
reason: verdict.reason,
|
|
1129
|
+
inFlight: this.admissionGate.getInFlight(),
|
|
1130
|
+
maxInFlight: this.admissionGate.getMaxInFlight(),
|
|
1131
|
+
});
|
|
1132
|
+
this.reportCameraOpen(s, { sensingState: 'waiting_for_capacity' });
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
try {
|
|
1136
|
+
let response;
|
|
1137
|
+
try {
|
|
1138
|
+
response = await s.deps.client.analyze({ jpeg, captureMode });
|
|
1139
|
+
}
|
|
1140
|
+
catch (err) {
|
|
1141
|
+
this.reportCameraOpen(s, { sensingState: 'unavailable' });
|
|
1142
|
+
throw err;
|
|
1143
|
+
}
|
|
1144
|
+
this.recordAnalysis(s, response, verdict.signature);
|
|
1145
|
+
return response;
|
|
1146
|
+
}
|
|
1147
|
+
finally {
|
|
1148
|
+
this.admissionGate.release(ticket);
|
|
947
1149
|
}
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* ADMIT FIRST, look second — the original order, unchanged, and the only one
|
|
1153
|
+
* available without a cascade: nothing has classified the frame, so paying
|
|
1154
|
+
* for a grab the model cannot take buys nothing, and a denied tick drops
|
|
1155
|
+
* without touching the camera. Video health stays independent: at most one
|
|
1156
|
+
* feed at a time may take a slow-cadence decode-only proof while the model
|
|
1157
|
+
* is occupied.
|
|
1158
|
+
*/
|
|
1159
|
+
async admitThenLook(s) {
|
|
948
1160
|
const ticket = this.admissionGate.tryAcquire(s.deps.name);
|
|
949
1161
|
if (!ticket) {
|
|
950
1162
|
s.admissionDeniedCount++;
|
|
@@ -980,8 +1192,10 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
980
1192
|
this.emit('frameCaptured', { name: s.deps.name, faceDetected: s.faceDetected });
|
|
981
1193
|
// Cheap stage: is this frame worth the expensive one? Releasing the slot
|
|
982
1194
|
// here (via the finally) costs the backend nothing but one grab.
|
|
1195
|
+
let signature = null;
|
|
983
1196
|
if (s.motionGate) {
|
|
984
1197
|
const verdict = await s.motionGate.evaluate(jpeg);
|
|
1198
|
+
signature = verdict.signature;
|
|
985
1199
|
if (this.motionDrivesCadence) {
|
|
986
1200
|
this.applyMotionCadence(s, verdict.motion);
|
|
987
1201
|
}
|
|
@@ -1017,19 +1231,148 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1017
1231
|
this.reportCameraOpen(s, { sensingState: 'unavailable' });
|
|
1018
1232
|
throw err;
|
|
1019
1233
|
}
|
|
1020
|
-
s
|
|
1021
|
-
s.runAnalyses++;
|
|
1022
|
-
// Stamp the heartbeat only once the call actually landed, so a frame lost
|
|
1023
|
-
// to the network cannot silence a feed for a whole heartbeat window.
|
|
1024
|
-
s.motionGate?.markAnalyzed();
|
|
1025
|
-
this.reportCameraOpen(s, { sensingState: 'active' });
|
|
1026
|
-
this.emit('analyzed', { name: s.deps.name, response });
|
|
1234
|
+
this.recordAnalysis(s, response, signature);
|
|
1027
1235
|
return response;
|
|
1028
1236
|
}
|
|
1029
1237
|
finally {
|
|
1030
1238
|
this.admissionGate.release(ticket);
|
|
1031
1239
|
}
|
|
1032
1240
|
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Everything that happens once a frame has been analyzed, in ONE place, so a
|
|
1243
|
+
* held frame and a fresh one can never be counted — or reported to the portal
|
|
1244
|
+
* — differently.
|
|
1245
|
+
*
|
|
1246
|
+
* Synchronous on purpose. The obvious factoring wraps the `analyze()` call
|
|
1247
|
+
* itself, but that turns every caller's inline await into an await of an
|
|
1248
|
+
* async helper, which adds microtask hops to the pre-existing capture path
|
|
1249
|
+
* for no behavioural gain. What the two callers genuinely share is the
|
|
1250
|
+
* bookkeeping AFTER the call, and that is what lives here; the four-line
|
|
1251
|
+
* try/catch around the call stays at each site, where its `unavailable`
|
|
1252
|
+
* report is visible next to the thing that can fail.
|
|
1253
|
+
*/
|
|
1254
|
+
recordAnalysis(s, response, signature) {
|
|
1255
|
+
s.postCount++;
|
|
1256
|
+
s.runAnalyses++;
|
|
1257
|
+
// Stamp the heartbeat AND the stationary baseline only once the call
|
|
1258
|
+
// actually landed, so a frame lost to the network, to an eviction or to the
|
|
1259
|
+
// freshness bound cannot silence a feed for a heartbeat window or suppress
|
|
1260
|
+
// the rest of its event as `stationary`.
|
|
1261
|
+
s.motionGate?.markAnalyzed(undefined, signature);
|
|
1262
|
+
this.reportCameraOpen(s, { sensingState: 'active' });
|
|
1263
|
+
this.emit('analyzed', { name: s.deps.name, response });
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Arm the candidate pump for ONE run of the manager.
|
|
1267
|
+
*
|
|
1268
|
+
* A manager-scoped chain rather than a per-feed one, and that is the point:
|
|
1269
|
+
* service order across feeds has to be a DECISION (least-recently-served,
|
|
1270
|
+
* see VlmCandidateBuffer), not a race between 32 independent timers all
|
|
1271
|
+
* polling for the same freed slot. `generation` carries the same meaning it
|
|
1272
|
+
* carries in `scheduleNext` — a pump that survives a stop()/start() cycle
|
|
1273
|
+
* must not arm a second chain.
|
|
1274
|
+
*/
|
|
1275
|
+
scheduleCandidatePump(generation) {
|
|
1276
|
+
if (!this.candidates || !this.isCurrentStart(generation))
|
|
1277
|
+
return;
|
|
1278
|
+
this.candidatePumpTimer = setTimeout(() => {
|
|
1279
|
+
if (!this.isCurrentStart(generation))
|
|
1280
|
+
return;
|
|
1281
|
+
try {
|
|
1282
|
+
this.pumpCandidates(generation);
|
|
1283
|
+
}
|
|
1284
|
+
finally {
|
|
1285
|
+
this.scheduleCandidatePump(generation);
|
|
1286
|
+
}
|
|
1287
|
+
}, clampTimerDelay(this.candidatePumpIntervalMs));
|
|
1288
|
+
// Never hold the process open for a poll that may have nothing to do.
|
|
1289
|
+
if (typeof this.candidatePumpTimer.unref === 'function')
|
|
1290
|
+
this.candidatePumpTimer.unref();
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Offer the hold to every slot that is free right now.
|
|
1294
|
+
*
|
|
1295
|
+
* Synchronous by construction: each admitted candidate is launched as its own
|
|
1296
|
+
* task and the pump does not await it, so one slow inference cannot delay the
|
|
1297
|
+
* next pump tick or another feed's turn. Concurrency stays bounded by the
|
|
1298
|
+
* admission gate, which is the only thing that ever knew the backend's real
|
|
1299
|
+
* capacity.
|
|
1300
|
+
*/
|
|
1301
|
+
pumpCandidates(generation) {
|
|
1302
|
+
const buffer = this.candidates;
|
|
1303
|
+
if (!buffer)
|
|
1304
|
+
return;
|
|
1305
|
+
// Age out first: a candidate past its TTL must never win a slot from one
|
|
1306
|
+
// that is still current.
|
|
1307
|
+
buffer.expire();
|
|
1308
|
+
// Bounded by the slot count — this loop can hand out capacity, never spin.
|
|
1309
|
+
for (let guard = this.admissionGate.getMaxInFlight(); guard > 0; guard--) {
|
|
1310
|
+
if (buffer.size() === 0)
|
|
1311
|
+
return;
|
|
1312
|
+
if (this.admissionGate.getInFlight() >= this.admissionGate.getMaxInFlight())
|
|
1313
|
+
return;
|
|
1314
|
+
const next = buffer.peek();
|
|
1315
|
+
if (!next)
|
|
1316
|
+
return;
|
|
1317
|
+
const s = this.loops.find((l) => l.deps.name === next.name);
|
|
1318
|
+
if (!s) {
|
|
1319
|
+
// Unreachable by construction: `loops` is fixed at construction and a
|
|
1320
|
+
// candidate is only ever offered under its own loop's name. If it ever
|
|
1321
|
+
// does happen, the frame cannot be analyzed and leaving it would block
|
|
1322
|
+
// the head of the rotation forever — so drop it, but LOUDLY. The drop
|
|
1323
|
+
// lands in the buffer's `served` counter, which would be a lie, and a
|
|
1324
|
+
// quiet lie in the accounting is the exact defect this buffer exists to
|
|
1325
|
+
// remove; the event is what makes it findable.
|
|
1326
|
+
buffer.take();
|
|
1327
|
+
this.emit('error', {
|
|
1328
|
+
name: next.name,
|
|
1329
|
+
error: new Error(`CloudSensingManager: held candidate for unknown feed "${next.name}" — dropped`),
|
|
1330
|
+
});
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
const ticket = this.admissionGate.tryAcquire(next.name);
|
|
1334
|
+
if (!ticket)
|
|
1335
|
+
return; // lost the slot to a live tick — try again next pump
|
|
1336
|
+
// Nothing above awaited, so this is the candidate `peek()` named.
|
|
1337
|
+
const candidate = buffer.take();
|
|
1338
|
+
if (!candidate) {
|
|
1339
|
+
this.admissionGate.release(ticket);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
void this.serveCandidate(s, candidate, ticket, generation);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
/** Analyze one held candidate and give the slot back. Never throws. */
|
|
1346
|
+
async serveCandidate(s, candidate, ticket, generation) {
|
|
1347
|
+
const waitedMs = Math.max(0, this.now() - candidate.enqueuedAtMs);
|
|
1348
|
+
try {
|
|
1349
|
+
let response;
|
|
1350
|
+
try {
|
|
1351
|
+
response = await s.deps.client.analyze({
|
|
1352
|
+
jpeg: candidate.jpeg,
|
|
1353
|
+
captureMode: candidate.captureMode,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
catch (err) {
|
|
1357
|
+
this.reportCameraOpen(s, { sensingState: 'unavailable' });
|
|
1358
|
+
throw err;
|
|
1359
|
+
}
|
|
1360
|
+
this.recordAnalysis(s, response, candidate.signature);
|
|
1361
|
+
if (this.isCurrentStart(generation)) {
|
|
1362
|
+
this.emit('candidateAnalyzed', {
|
|
1363
|
+
name: s.deps.name,
|
|
1364
|
+
waitedMs,
|
|
1365
|
+
reason: candidate.reason,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
catch (err) {
|
|
1370
|
+
this.emitLoopError(s, err);
|
|
1371
|
+
}
|
|
1372
|
+
finally {
|
|
1373
|
+
this.admissionGate.release(ticket);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1033
1376
|
/**
|
|
1034
1377
|
* Convert a camera frame to JPEG bytes for the POST.
|
|
1035
1378
|
*
|
|
@@ -1105,6 +1448,72 @@ function localInferencesFor(s) {
|
|
|
1105
1448
|
}
|
|
1106
1449
|
return 0;
|
|
1107
1450
|
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Read a feed's on-device accelerator verdict defensively. The cloud client
|
|
1453
|
+
* does not implement `getAcceleratorHealth` (it runs no model), and a test
|
|
1454
|
+
* double may not either, so an absent method reads as "this feed has nothing
|
|
1455
|
+
* to say about a delegate" — never as an error on the health path.
|
|
1456
|
+
*/
|
|
1457
|
+
function acceleratorHealthFor(s) {
|
|
1458
|
+
try {
|
|
1459
|
+
return s.deps.client.getAcceleratorHealth?.() ?? null;
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
// health must never throw
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Fold N per-feed accelerator verdicts into the one an operator reads.
|
|
1468
|
+
*
|
|
1469
|
+
* PESSIMISTIC BY CONSTRUCTION. A majority vote here would do the exact thing
|
|
1470
|
+
* this surface exists to stop: hide a slow feed behind fast ones. So a split
|
|
1471
|
+
* delegate is reported as `'mixed'` rather than resolved, any demotion makes
|
|
1472
|
+
* the whole verdict demoted, and the reason preferentially comes from a feed
|
|
1473
|
+
* that actually has a problem rather than from feed 0.
|
|
1474
|
+
*/
|
|
1475
|
+
function foldAcceleratorHealth(reports) {
|
|
1476
|
+
if (reports.length === 0) {
|
|
1477
|
+
return {
|
|
1478
|
+
feeds: 0,
|
|
1479
|
+
policy: null,
|
|
1480
|
+
delegate: null,
|
|
1481
|
+
gpuAdvertised: null,
|
|
1482
|
+
gpuDemoted: false,
|
|
1483
|
+
reason: null,
|
|
1484
|
+
models: [],
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
const policies = new Set(reports.map((r) => r.accelerator));
|
|
1488
|
+
// A delegate is only settled when EVERY feed has settled on the same one. A
|
|
1489
|
+
// feed that has not resolved yet cannot be counted as agreeing with the rest.
|
|
1490
|
+
const delegates = new Set(reports.map((r) => r.selectedDelegate));
|
|
1491
|
+
const delegate = delegates.size > 1
|
|
1492
|
+
? 'mixed'
|
|
1493
|
+
: (reports[0].selectedDelegate ?? null);
|
|
1494
|
+
// The variant either exists on that serve or it does not: one feed seeing it
|
|
1495
|
+
// settles the question affirmatively. Only when nobody probed is it unknown.
|
|
1496
|
+
const gpuAdvertised = reports.some((r) => r.gpuAdvertised === true)
|
|
1497
|
+
? true
|
|
1498
|
+
: reports.some((r) => r.gpuAdvertised === false)
|
|
1499
|
+
? false
|
|
1500
|
+
: null;
|
|
1501
|
+
// Prefer the reason from a DEMOTED feed (a fault worth acting on), then any
|
|
1502
|
+
// feed that is not on the GPU, then whatever is left.
|
|
1503
|
+
const reason = reports.find((r) => r.gpuDemoted && r.reason)?.reason ??
|
|
1504
|
+
reports.find((r) => r.selectedDelegate !== 'gpu' && r.reason)?.reason ??
|
|
1505
|
+
reports.find((r) => r.reason)?.reason ??
|
|
1506
|
+
null;
|
|
1507
|
+
return {
|
|
1508
|
+
feeds: reports.length,
|
|
1509
|
+
policy: policies.size === 1 ? reports[0].accelerator : null,
|
|
1510
|
+
delegate,
|
|
1511
|
+
gpuAdvertised,
|
|
1512
|
+
gpuDemoted: reports.some((r) => r.gpuDemoted),
|
|
1513
|
+
reason,
|
|
1514
|
+
models: [...new Set(reports.map((r) => r.selectedModel))].sort(),
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1108
1517
|
function escalationStatsFor(s) {
|
|
1109
1518
|
try {
|
|
1110
1519
|
const stats = s.deps.client.getEscalationStats?.();
|