@trillboards/edge-sdk 0.2.14 → 0.2.16
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/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 +48 -4
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
- package/dist/AudienceAnalyzeClient.js +33 -2
- package/dist/AudienceAnalyzeClient.js.map +1 -1
- package/dist/CloudSensingManager.d.ts +157 -7
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +392 -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 +247 -0
- package/dist/VlmCandidateBuffer.d.ts.map +1 -0
- package/dist/VlmCandidateBuffer.js +349 -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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/localSensing.d.ts +2 -2
- package/dist/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +16 -0
- package/dist/localSensing.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 +40 -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,6 +491,21 @@ 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),
|
|
452
511
|
accelerator: foldAcceleratorHealth(this.loops.map((s) => acceleratorHealthFor(s)).filter((a) => a !== null)),
|
|
@@ -459,6 +518,8 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
459
518
|
motionSkippedByReason: gateStats[i]
|
|
460
519
|
? { ...emptyMotionReasonCounts(), ...gateStats[i].byReason }
|
|
461
520
|
: emptyMotionReasonCounts(),
|
|
521
|
+
candidatesBuffered: candidateByFeed.get(s.deps.name)?.buffered ?? 0,
|
|
522
|
+
candidatesServed: candidateByFeed.get(s.deps.name)?.served ?? 0,
|
|
462
523
|
cloudEscalated: escalation[i].escalated,
|
|
463
524
|
faceDetected: s.faceDetected,
|
|
464
525
|
currentIntervalMs: this.intervalFor(s),
|
|
@@ -486,6 +547,11 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
486
547
|
s.runMotionSkipped = 0;
|
|
487
548
|
void this.startLoop(s, generation).catch((err) => this.emitLoopError(s, err));
|
|
488
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);
|
|
489
555
|
// Ask once at startup so a connector picks up its deployed cadence on boot
|
|
490
556
|
// rather than after a full refresh period, then keep asking. Both are
|
|
491
557
|
// best-effort: refreshDeployedCadence never throws and never resets the
|
|
@@ -509,6 +575,12 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
509
575
|
// closed against the NEXT run.
|
|
510
576
|
this.admissionGate.reset();
|
|
511
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();
|
|
512
584
|
if (this.cadenceTimer) {
|
|
513
585
|
clearInterval(this.cadenceTimer);
|
|
514
586
|
this.cadenceTimer = null;
|
|
@@ -623,7 +695,9 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
623
695
|
}
|
|
624
696
|
return;
|
|
625
697
|
}
|
|
626
|
-
|
|
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' });
|
|
627
701
|
}
|
|
628
702
|
catch (err) {
|
|
629
703
|
if (!this.isCurrentStart(generation)) {
|
|
@@ -828,7 +902,11 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
828
902
|
// frame as "changed". The heartbeat clock is deliberately NOT reset: an
|
|
829
903
|
// outage must not delay the feed's next guaranteed report.
|
|
830
904
|
s.motionGate?.reset();
|
|
831
|
-
|
|
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' });
|
|
832
910
|
this.emit('reconnected', { name: s.deps.name });
|
|
833
911
|
return true;
|
|
834
912
|
}
|
|
@@ -887,14 +965,35 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
887
965
|
});
|
|
888
966
|
});
|
|
889
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
|
+
*/
|
|
890
984
|
reportCameraOpen(s, options = {}) {
|
|
891
985
|
const sensingState = options.sensingState ?? s.lastSensingState ?? 'idle';
|
|
892
986
|
const now = this.now();
|
|
893
987
|
const stateChanged = s.lastSensingState !== sensingState;
|
|
894
988
|
const heartbeatDue = s.lastHealthReportAtMs == null ||
|
|
895
989
|
now - s.lastHealthReportAtMs >= this.cameraHealthHeartbeatIntervalMs;
|
|
896
|
-
if (!options.frameProven &&
|
|
990
|
+
if (!options.frameProven &&
|
|
991
|
+
!options.streamOpened &&
|
|
992
|
+
!options.force &&
|
|
993
|
+
!stateChanged &&
|
|
994
|
+
!heartbeatDue) {
|
|
897
995
|
return;
|
|
996
|
+
}
|
|
898
997
|
if (options.frameProven)
|
|
899
998
|
s.lastCaptureAtMs = now;
|
|
900
999
|
this.reportCameraHealth(s, {
|
|
@@ -903,7 +1002,9 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
903
1002
|
sensingState,
|
|
904
1003
|
...(options.frameProven
|
|
905
1004
|
? { lastFrameAtMs: now, captureProof: 'decoded_frame' }
|
|
906
|
-
:
|
|
1005
|
+
: options.streamOpened
|
|
1006
|
+
? { captureProof: 'stream_open' }
|
|
1007
|
+
: {}),
|
|
907
1008
|
});
|
|
908
1009
|
}
|
|
909
1010
|
healthProbeDue(s) {
|
|
@@ -935,17 +1036,141 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
935
1036
|
}
|
|
936
1037
|
}
|
|
937
1038
|
/**
|
|
938
|
-
* One cascade pass for a feed
|
|
939
|
-
*
|
|
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.
|
|
940
1042
|
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
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.
|
|
944
1050
|
*/
|
|
945
|
-
|
|
1051
|
+
captureAndPost(s) {
|
|
946
1052
|
if (!s.deps.camera.isOpen()) {
|
|
947
|
-
|
|
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
|
+
// Travels WITH the frame. A candidate can be served long after the
|
|
1119
|
+
// camera moved on, so re-reading the camera at serve time would
|
|
1120
|
+
// describe a different frame than the one being analyzed.
|
|
1121
|
+
imageWidth: res?.width,
|
|
1122
|
+
imageHeight: res?.height,
|
|
1123
|
+
}) === 'buffered';
|
|
1124
|
+
this.emit('admissionDenied', {
|
|
1125
|
+
name: s.deps.name,
|
|
1126
|
+
// No short retry either way. Unlike the no-cascade path, this tick has
|
|
1127
|
+
// already LOOKED: a held frame is safe until the pump takes it, and a
|
|
1128
|
+
// frame we chose not to hold is one whose successor is strictly
|
|
1129
|
+
// better — so the right next move is the normal scan cadence, not a
|
|
1130
|
+
// 250 ms re-grab of the same scene on all 32 feeds at once.
|
|
1131
|
+
retryInMs: null,
|
|
1132
|
+
buffered,
|
|
1133
|
+
reason: verdict.reason,
|
|
1134
|
+
inFlight: this.admissionGate.getInFlight(),
|
|
1135
|
+
maxInFlight: this.admissionGate.getMaxInFlight(),
|
|
1136
|
+
});
|
|
1137
|
+
this.reportCameraOpen(s, { sensingState: 'waiting_for_capacity' });
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
let response;
|
|
1142
|
+
try {
|
|
1143
|
+
// Same measured dimensions the encoder used, on the cascade path too —
|
|
1144
|
+
// this is the path the multi-channel CCTV connector actually runs.
|
|
1145
|
+
// Same measured dimensions the encoder used, on the cascade path too —
|
|
1146
|
+
// this is the path the multi-channel CCTV connector actually runs.
|
|
1147
|
+
response = await s.deps.client.analyze({
|
|
1148
|
+
jpeg,
|
|
1149
|
+
captureMode,
|
|
1150
|
+
imageWidth: res?.width,
|
|
1151
|
+
imageHeight: res?.height,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
catch (err) {
|
|
1155
|
+
this.reportCameraOpen(s, { sensingState: 'unavailable' });
|
|
1156
|
+
throw err;
|
|
1157
|
+
}
|
|
1158
|
+
this.recordAnalysis(s, response, verdict.signature);
|
|
1159
|
+
return response;
|
|
1160
|
+
}
|
|
1161
|
+
finally {
|
|
1162
|
+
this.admissionGate.release(ticket);
|
|
948
1163
|
}
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* ADMIT FIRST, look second — the original order, unchanged, and the only one
|
|
1167
|
+
* available without a cascade: nothing has classified the frame, so paying
|
|
1168
|
+
* for a grab the model cannot take buys nothing, and a denied tick drops
|
|
1169
|
+
* without touching the camera. Video health stays independent: at most one
|
|
1170
|
+
* feed at a time may take a slow-cadence decode-only proof while the model
|
|
1171
|
+
* is occupied.
|
|
1172
|
+
*/
|
|
1173
|
+
async admitThenLook(s) {
|
|
949
1174
|
const ticket = this.admissionGate.tryAcquire(s.deps.name);
|
|
950
1175
|
if (!ticket) {
|
|
951
1176
|
s.admissionDeniedCount++;
|
|
@@ -981,8 +1206,10 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
981
1206
|
this.emit('frameCaptured', { name: s.deps.name, faceDetected: s.faceDetected });
|
|
982
1207
|
// Cheap stage: is this frame worth the expensive one? Releasing the slot
|
|
983
1208
|
// here (via the finally) costs the backend nothing but one grab.
|
|
1209
|
+
let signature = null;
|
|
984
1210
|
if (s.motionGate) {
|
|
985
1211
|
const verdict = await s.motionGate.evaluate(jpeg);
|
|
1212
|
+
signature = verdict.signature;
|
|
986
1213
|
if (this.motionDrivesCadence) {
|
|
987
1214
|
this.applyMotionCadence(s, verdict.motion);
|
|
988
1215
|
}
|
|
@@ -1012,25 +1239,167 @@ class CloudSensingManager extends events_1.EventEmitter {
|
|
|
1012
1239
|
response = await s.deps.client.analyze({
|
|
1013
1240
|
jpeg,
|
|
1014
1241
|
captureMode: s.faceDetected ? 'face_triggered' : 'periodic',
|
|
1242
|
+
// The dimensions we ALREADY decoded this frame at, above. They were
|
|
1243
|
+
// computed only to drive the JPEG encoder and then dropped, while the
|
|
1244
|
+
// wire carried the client's per-feed configured width instead — a
|
|
1245
|
+
// value that is absent on exactly the multi-channel feeds where the
|
|
1246
|
+
// question "what resolution did this camera actually deliver?" is
|
|
1247
|
+
// asked. Undefined when the provider implements no getResolution(),
|
|
1248
|
+
// in which case the client falls back to its constructor value.
|
|
1249
|
+
imageWidth: res?.width,
|
|
1250
|
+
imageHeight: res?.height,
|
|
1015
1251
|
});
|
|
1016
1252
|
}
|
|
1017
1253
|
catch (err) {
|
|
1018
1254
|
this.reportCameraOpen(s, { sensingState: 'unavailable' });
|
|
1019
1255
|
throw err;
|
|
1020
1256
|
}
|
|
1021
|
-
s
|
|
1022
|
-
s.runAnalyses++;
|
|
1023
|
-
// Stamp the heartbeat only once the call actually landed, so a frame lost
|
|
1024
|
-
// to the network cannot silence a feed for a whole heartbeat window.
|
|
1025
|
-
s.motionGate?.markAnalyzed();
|
|
1026
|
-
this.reportCameraOpen(s, { sensingState: 'active' });
|
|
1027
|
-
this.emit('analyzed', { name: s.deps.name, response });
|
|
1257
|
+
this.recordAnalysis(s, response, signature);
|
|
1028
1258
|
return response;
|
|
1029
1259
|
}
|
|
1030
1260
|
finally {
|
|
1031
1261
|
this.admissionGate.release(ticket);
|
|
1032
1262
|
}
|
|
1033
1263
|
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Everything that happens once a frame has been analyzed, in ONE place, so a
|
|
1266
|
+
* held frame and a fresh one can never be counted — or reported to the portal
|
|
1267
|
+
* — differently.
|
|
1268
|
+
*
|
|
1269
|
+
* Synchronous on purpose. The obvious factoring wraps the `analyze()` call
|
|
1270
|
+
* itself, but that turns every caller's inline await into an await of an
|
|
1271
|
+
* async helper, which adds microtask hops to the pre-existing capture path
|
|
1272
|
+
* for no behavioural gain. What the two callers genuinely share is the
|
|
1273
|
+
* bookkeeping AFTER the call, and that is what lives here; the four-line
|
|
1274
|
+
* try/catch around the call stays at each site, where its `unavailable`
|
|
1275
|
+
* report is visible next to the thing that can fail.
|
|
1276
|
+
*/
|
|
1277
|
+
recordAnalysis(s, response, signature) {
|
|
1278
|
+
s.postCount++;
|
|
1279
|
+
s.runAnalyses++;
|
|
1280
|
+
// Stamp the heartbeat AND the stationary baseline only once the call
|
|
1281
|
+
// actually landed, so a frame lost to the network, to an eviction or to the
|
|
1282
|
+
// freshness bound cannot silence a feed for a heartbeat window or suppress
|
|
1283
|
+
// the rest of its event as `stationary`.
|
|
1284
|
+
s.motionGate?.markAnalyzed(undefined, signature);
|
|
1285
|
+
this.reportCameraOpen(s, { sensingState: 'active' });
|
|
1286
|
+
this.emit('analyzed', { name: s.deps.name, response });
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Arm the candidate pump for ONE run of the manager.
|
|
1290
|
+
*
|
|
1291
|
+
* A manager-scoped chain rather than a per-feed one, and that is the point:
|
|
1292
|
+
* service order across feeds has to be a DECISION (least-recently-served,
|
|
1293
|
+
* see VlmCandidateBuffer), not a race between 32 independent timers all
|
|
1294
|
+
* polling for the same freed slot. `generation` carries the same meaning it
|
|
1295
|
+
* carries in `scheduleNext` — a pump that survives a stop()/start() cycle
|
|
1296
|
+
* must not arm a second chain.
|
|
1297
|
+
*/
|
|
1298
|
+
scheduleCandidatePump(generation) {
|
|
1299
|
+
if (!this.candidates || !this.isCurrentStart(generation))
|
|
1300
|
+
return;
|
|
1301
|
+
this.candidatePumpTimer = setTimeout(() => {
|
|
1302
|
+
if (!this.isCurrentStart(generation))
|
|
1303
|
+
return;
|
|
1304
|
+
try {
|
|
1305
|
+
this.pumpCandidates(generation);
|
|
1306
|
+
}
|
|
1307
|
+
finally {
|
|
1308
|
+
this.scheduleCandidatePump(generation);
|
|
1309
|
+
}
|
|
1310
|
+
}, clampTimerDelay(this.candidatePumpIntervalMs));
|
|
1311
|
+
// Never hold the process open for a poll that may have nothing to do.
|
|
1312
|
+
if (typeof this.candidatePumpTimer.unref === 'function')
|
|
1313
|
+
this.candidatePumpTimer.unref();
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Offer the hold to every slot that is free right now.
|
|
1317
|
+
*
|
|
1318
|
+
* Synchronous by construction: each admitted candidate is launched as its own
|
|
1319
|
+
* task and the pump does not await it, so one slow inference cannot delay the
|
|
1320
|
+
* next pump tick or another feed's turn. Concurrency stays bounded by the
|
|
1321
|
+
* admission gate, which is the only thing that ever knew the backend's real
|
|
1322
|
+
* capacity.
|
|
1323
|
+
*/
|
|
1324
|
+
pumpCandidates(generation) {
|
|
1325
|
+
const buffer = this.candidates;
|
|
1326
|
+
if (!buffer)
|
|
1327
|
+
return;
|
|
1328
|
+
// Age out first: a candidate past its TTL must never win a slot from one
|
|
1329
|
+
// that is still current.
|
|
1330
|
+
buffer.expire();
|
|
1331
|
+
// Bounded by the slot count — this loop can hand out capacity, never spin.
|
|
1332
|
+
for (let guard = this.admissionGate.getMaxInFlight(); guard > 0; guard--) {
|
|
1333
|
+
if (buffer.size() === 0)
|
|
1334
|
+
return;
|
|
1335
|
+
if (this.admissionGate.getInFlight() >= this.admissionGate.getMaxInFlight())
|
|
1336
|
+
return;
|
|
1337
|
+
const next = buffer.peek();
|
|
1338
|
+
if (!next)
|
|
1339
|
+
return;
|
|
1340
|
+
const s = this.loops.find((l) => l.deps.name === next.name);
|
|
1341
|
+
if (!s) {
|
|
1342
|
+
// Unreachable by construction: `loops` is fixed at construction and a
|
|
1343
|
+
// candidate is only ever offered under its own loop's name. If it ever
|
|
1344
|
+
// does happen, the frame cannot be analyzed and leaving it would block
|
|
1345
|
+
// the head of the rotation forever — so drop it, but LOUDLY. The drop
|
|
1346
|
+
// lands in the buffer's `served` counter, which would be a lie, and a
|
|
1347
|
+
// quiet lie in the accounting is the exact defect this buffer exists to
|
|
1348
|
+
// remove; the event is what makes it findable.
|
|
1349
|
+
buffer.take();
|
|
1350
|
+
this.emit('error', {
|
|
1351
|
+
name: next.name,
|
|
1352
|
+
error: new Error(`CloudSensingManager: held candidate for unknown feed "${next.name}" — dropped`),
|
|
1353
|
+
});
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
const ticket = this.admissionGate.tryAcquire(next.name);
|
|
1357
|
+
if (!ticket)
|
|
1358
|
+
return; // lost the slot to a live tick — try again next pump
|
|
1359
|
+
// Nothing above awaited, so this is the candidate `peek()` named.
|
|
1360
|
+
const candidate = buffer.take();
|
|
1361
|
+
if (!candidate) {
|
|
1362
|
+
this.admissionGate.release(ticket);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
void this.serveCandidate(s, candidate, ticket, generation);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
/** Analyze one held candidate and give the slot back. Never throws. */
|
|
1369
|
+
async serveCandidate(s, candidate, ticket, generation) {
|
|
1370
|
+
const waitedMs = Math.max(0, this.now() - candidate.enqueuedAtMs);
|
|
1371
|
+
try {
|
|
1372
|
+
let response;
|
|
1373
|
+
try {
|
|
1374
|
+
response = await s.deps.client.analyze({
|
|
1375
|
+
jpeg: candidate.jpeg,
|
|
1376
|
+
captureMode: candidate.captureMode,
|
|
1377
|
+
// The dimensions recorded when this frame was CAPTURED, not whatever
|
|
1378
|
+
// the camera reports now — the frame may have waited in the buffer.
|
|
1379
|
+
imageWidth: candidate.imageWidth,
|
|
1380
|
+
imageHeight: candidate.imageHeight,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
catch (err) {
|
|
1384
|
+
this.reportCameraOpen(s, { sensingState: 'unavailable' });
|
|
1385
|
+
throw err;
|
|
1386
|
+
}
|
|
1387
|
+
this.recordAnalysis(s, response, candidate.signature);
|
|
1388
|
+
if (this.isCurrentStart(generation)) {
|
|
1389
|
+
this.emit('candidateAnalyzed', {
|
|
1390
|
+
name: s.deps.name,
|
|
1391
|
+
waitedMs,
|
|
1392
|
+
reason: candidate.reason,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
catch (err) {
|
|
1397
|
+
this.emitLoopError(s, err);
|
|
1398
|
+
}
|
|
1399
|
+
finally {
|
|
1400
|
+
this.admissionGate.release(ticket);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1034
1403
|
/**
|
|
1035
1404
|
* Convert a camera frame to JPEG bytes for the POST.
|
|
1036
1405
|
*
|