@trillboards/edge-sdk 0.2.140 → 0.2.150
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/README.md +2 -2
- package/deploy/detector/pyproject.toml +20 -2
- package/deploy/detector/tests/test_execution.py +124 -0
- package/deploy/detector/tests/test_model_identity.py +80 -0
- package/deploy/detector/tests/test_registry.py +18 -0
- package/deploy/detector/tests/test_server.py +3 -1
- package/deploy/detector/tests/test_version_stamp.py +4 -1
- package/deploy/detector/trillboards_detector/_version.py +2 -2
- package/deploy/detector/trillboards_detector/cli.py +3 -0
- package/deploy/detector/trillboards_detector/detections.py +7 -0
- package/deploy/detector/trillboards_detector/errors.py +4 -0
- package/deploy/detector/trillboards_detector/execution.py +109 -0
- package/deploy/detector/trillboards_detector/loader.py +6 -3
- package/deploy/detector/trillboards_detector/registry.py +27 -5
- package/deploy/detector/trillboards_detector/server.py +29 -2
- package/deploy/detector/trillboards_detector/session.py +15 -3
- package/deploy/litertlm/test_trillboards_litertlm_shim.py +81 -0
- package/deploy/litertlm/test_verify_model_proof.py +30 -0
- package/deploy/litertlm/trillboards_litertlm_shim.py +150 -1
- package/deploy/litertlm/verify_model_proof.py +30 -0
- package/deploy/macos/build-macos-pkg.sh +10 -10
- package/deploy/macos/scripts/setup-detector.sh +2 -2
- package/deploy/macos/scripts/setup-litertlm.sh +66 -30
- package/deploy/macos/test-setup-litertlm-reinstall.sh +57 -4
- package/deploy/systemd/setup-detector.sh +2 -2
- package/deploy/systemd/setup-litertlm.sh +51 -27
- package/deploy/windows/build-windows.ps1 +10 -6
- package/deploy/windows/bundle-windows-installer.ps1 +3 -3
- package/deploy/windows/e2e/test-local-sense.ps1 +6 -5
- package/deploy/windows/install-update.ps1 +52 -9
- package/deploy/windows/set-executable-icon.ps1 +24 -5
- package/deploy/windows/setup-detector.ps1 +142 -4
- package/deploy/windows/setup-litertlm.ps1 +53 -10
- package/dist/AudienceAnalyzeClient.d.ts +10 -0
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
- package/dist/AudienceAnalyzeClient.js +5 -4
- package/dist/AudienceAnalyzeClient.js.map +1 -1
- package/dist/CloudSensingManager.d.ts +38 -1
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +403 -25
- package/dist/CloudSensingManager.js.map +1 -1
- package/dist/DetectorFieldProducer.d.ts +5 -1
- package/dist/DetectorFieldProducer.d.ts.map +1 -1
- package/dist/DetectorFieldProducer.js +8 -1
- package/dist/DetectorFieldProducer.js.map +1 -1
- package/dist/DurableVlmCandidateSpool.d.ts +83 -2
- package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
- package/dist/DurableVlmCandidateSpool.js +596 -30
- package/dist/DurableVlmCandidateSpool.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/FrameGroupingShadow.d.ts +51 -0
- package/dist/FrameGroupingShadow.d.ts.map +1 -0
- package/dist/FrameGroupingShadow.js +158 -0
- package/dist/FrameGroupingShadow.js.map +1 -0
- package/dist/ImageClassifierFieldProducer.d.ts +36 -0
- package/dist/ImageClassifierFieldProducer.d.ts.map +1 -0
- package/dist/ImageClassifierFieldProducer.js +150 -0
- package/dist/ImageClassifierFieldProducer.js.map +1 -0
- package/dist/LiteRtVlmProvider.d.ts +5 -0
- package/dist/LiteRtVlmProvider.d.ts.map +1 -1
- package/dist/LiteRtVlmProvider.js +72 -7
- package/dist/LiteRtVlmProvider.js.map +1 -1
- package/dist/LocalDetectorClient.d.ts +6 -0
- package/dist/LocalDetectorClient.d.ts.map +1 -1
- package/dist/LocalDetectorClient.js +8 -0
- package/dist/LocalDetectorClient.js.map +1 -1
- package/dist/OrderedFrameReview.d.ts +57 -0
- package/dist/OrderedFrameReview.d.ts.map +1 -0
- package/dist/OrderedFrameReview.js +121 -0
- package/dist/OrderedFrameReview.js.map +1 -0
- package/dist/OrderedReviewClient.d.ts +38 -0
- package/dist/OrderedReviewClient.d.ts.map +1 -0
- package/dist/OrderedReviewClient.js +121 -0
- package/dist/OrderedReviewClient.js.map +1 -0
- package/dist/OrderedSequenceStore.d.ts +69 -0
- package/dist/OrderedSequenceStore.d.ts.map +1 -0
- package/dist/OrderedSequenceStore.js +257 -0
- package/dist/OrderedSequenceStore.js.map +1 -0
- package/dist/ProfileReviewWork.d.ts +68 -0
- package/dist/ProfileReviewWork.d.ts.map +1 -0
- package/dist/ProfileReviewWork.js +150 -0
- package/dist/ProfileReviewWork.js.map +1 -0
- package/dist/RetainedFieldContract.d.ts +21 -0
- package/dist/RetainedFieldContract.d.ts.map +1 -0
- package/dist/RetainedFieldContract.js +92 -0
- package/dist/RetainedFieldContract.js.map +1 -0
- package/dist/ReviewModelUse.d.ts +7 -0
- package/dist/ReviewModelUse.d.ts.map +1 -0
- package/dist/ReviewModelUse.js +30 -0
- package/dist/ReviewModelUse.js.map +1 -0
- package/dist/ReviewPriority.d.ts +12 -0
- package/dist/ReviewPriority.d.ts.map +1 -0
- package/dist/ReviewPriority.js +45 -0
- package/dist/ReviewPriority.js.map +1 -0
- package/dist/RuntimeModelSelection.d.ts +10 -0
- package/dist/RuntimeModelSelection.d.ts.map +1 -0
- package/dist/RuntimeModelSelection.js +58 -0
- package/dist/RuntimeModelSelection.js.map +1 -0
- package/dist/SceneChangeDetector.d.ts +1 -1
- package/dist/SceneChangeDetector.d.ts.map +1 -1
- package/dist/SceneChangeDetector.js +18 -6
- package/dist/SceneChangeDetector.js.map +1 -1
- package/dist/ScopedVlmReview.d.ts +76 -0
- package/dist/ScopedVlmReview.d.ts.map +1 -0
- package/dist/ScopedVlmReview.js +267 -0
- package/dist/ScopedVlmReview.js.map +1 -0
- package/dist/SensingProfileClient.d.ts +50 -0
- package/dist/SensingProfileClient.d.ts.map +1 -1
- package/dist/SensingProfileClient.js +58 -2
- package/dist/SensingProfileClient.js.map +1 -1
- package/dist/TemporalReviewClient.d.ts +43 -0
- package/dist/TemporalReviewClient.d.ts.map +1 -0
- package/dist/TemporalReviewClient.js +117 -0
- package/dist/TemporalReviewClient.js.map +1 -0
- package/dist/TemporalReviewJournal.d.ts +91 -0
- package/dist/TemporalReviewJournal.d.ts.map +1 -0
- package/dist/TemporalReviewJournal.js +337 -0
- package/dist/TemporalReviewJournal.js.map +1 -0
- package/dist/UrgentReviewTriage.d.ts +35 -0
- package/dist/UrgentReviewTriage.d.ts.map +1 -0
- package/dist/UrgentReviewTriage.js +52 -0
- package/dist/UrgentReviewTriage.js.map +1 -0
- package/dist/VideoSegmentUploader.d.ts +293 -0
- package/dist/VideoSegmentUploader.d.ts.map +1 -0
- package/dist/VideoSegmentUploader.js +1002 -0
- package/dist/VideoSegmentUploader.js.map +1 -0
- package/dist/VlmCandidateBuffer.d.ts +19 -0
- package/dist/VlmCandidateBuffer.d.ts.map +1 -1
- package/dist/VlmCandidateBuffer.js.map +1 -1
- package/dist/autoSense.d.ts +2 -2
- package/dist/autoSense.d.ts.map +1 -1
- package/dist/autoSense.js +16 -11
- package/dist/autoSense.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/continuousReview.d.ts +71 -0
- package/dist/continuousReview.d.ts.map +1 -0
- package/dist/continuousReview.js +53 -0
- package/dist/continuousReview.js.map +1 -0
- package/dist/detectorCountReview.d.ts +9 -0
- package/dist/detectorCountReview.d.ts.map +1 -0
- package/dist/detectorCountReview.js +79 -0
- package/dist/detectorCountReview.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/installerOnboardingMode.d.ts.map +1 -1
- package/dist/installerOnboardingMode.js +5 -3
- package/dist/installerOnboardingMode.js.map +1 -1
- package/dist/localInferenceSchema.d.ts +8 -0
- package/dist/localInferenceSchema.d.ts.map +1 -1
- package/dist/localInferenceSchema.js +3 -3
- package/dist/localInferenceSchema.js.map +1 -1
- package/dist/localSenseE2eHarness.d.ts +12 -0
- package/dist/localSenseE2eHarness.d.ts.map +1 -1
- package/dist/localSenseE2eHarness.js +77 -4
- package/dist/localSenseE2eHarness.js.map +1 -1
- package/dist/localSensing.d.ts +91 -18
- package/dist/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +678 -153
- package/dist/localSensing.js.map +1 -1
- package/dist/mixedScopedCounts.d.ts +17 -0
- package/dist/mixedScopedCounts.d.ts.map +1 -0
- package/dist/mixedScopedCounts.js +90 -0
- package/dist/mixedScopedCounts.js.map +1 -0
- package/dist/sensingCatchRate.d.ts +9 -5
- package/dist/sensingCatchRate.d.ts.map +1 -1
- package/dist/sensingCatchRate.js +10 -6
- package/dist/sensingCatchRate.js.map +1 -1
- package/dist/sensingRunMode.d.ts +5 -0
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +56 -0
- package/dist/sensingRunMode.js.map +1 -1
- package/package.json +7 -4
- package/retained/ArchiveLifecycle.cjs +79 -0
- package/retained/ModelStageScheduler.cjs +66 -0
- package/retained/ProfileAuthorityClient.cjs +88 -0
- package/retained/RetainedCaptureClient.cjs +164 -0
- package/retained/WaitRecheckCaptureClient.cjs +109 -0
- package/retained/__tests__/archive-lifecycle.test.cjs +117 -0
- package/retained/__tests__/runtime.test.cjs +384 -0
- package/retained/alert-rule-policy.cjs +37 -0
- package/retained/manager.cjs +133 -0
- package/retained/pilot-profile-client.cjs +82 -0
- package/retained/temporal-grouping.cjs +78 -0
- package/retained/website-api-capability.cjs +122 -0
package/dist/localSensing.js
CHANGED
|
@@ -1,20 +1,68 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.LocalSensingClient = void 0;
|
|
36
|
+
exports.VIDEO_SEGMENTS_DIRNAME = exports.LocalSensingClient = void 0;
|
|
4
37
|
exports.fieldSetForProfileType = fieldSetForProfileType;
|
|
5
38
|
exports.repairLowConfidencePresenceContradiction = repairLowConfidencePresenceContradiction;
|
|
6
39
|
exports.isAlertWorthy = isAlertWorthy;
|
|
7
40
|
exports.resolveMaxStreamSessionsPerSource = resolveMaxStreamSessionsPerSource;
|
|
41
|
+
exports.recordingUrlKey = recordingUrlKey;
|
|
42
|
+
exports.videoFeedKey = videoFeedKey;
|
|
43
|
+
exports.getVideoRecordingPlan = getVideoRecordingPlan;
|
|
8
44
|
exports.buildLocalSensingLoops = buildLocalSensingLoops;
|
|
9
45
|
const crypto_1 = require("crypto");
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const dataDir_1 = require("./dataDir");
|
|
48
|
+
const VideoSegmentUploader_1 = require("./VideoSegmentUploader");
|
|
10
49
|
const AudienceAnalyzeClient_1 = require("./AudienceAnalyzeClient");
|
|
11
50
|
const LiteRtVlmProvider_1 = require("./LiteRtVlmProvider");
|
|
12
51
|
const analyzeCostObserver_1 = require("./analyzeCostObserver");
|
|
52
|
+
const continuousReview_1 = require("./continuousReview");
|
|
53
|
+
const ProfileReviewWork_1 = require("./ProfileReviewWork");
|
|
13
54
|
const DetectorFieldProducer_1 = require("./DetectorFieldProducer");
|
|
14
55
|
const SensingProfileClient_1 = require("./SensingProfileClient");
|
|
56
|
+
const detectorCountReview_1 = require("./detectorCountReview");
|
|
57
|
+
const jpegFrameSize_1 = require("./jpegFrameSize");
|
|
58
|
+
const ScopedVlmReview_1 = require("./ScopedVlmReview");
|
|
15
59
|
const CloudSensingManager_1 = require("./CloudSensingManager");
|
|
16
60
|
const localInferenceSchema_1 = require("./localInferenceSchema");
|
|
17
61
|
const legacyRecorderProfile_1 = require("./legacyRecorderProfile");
|
|
62
|
+
const ReviewPriority_1 = require("./ReviewPriority");
|
|
63
|
+
const RetainedFieldContract_1 = require("./RetainedFieldContract");
|
|
64
|
+
const RuntimeModelSelection_1 = require("./RuntimeModelSelection");
|
|
65
|
+
const ReviewModelUse_1 = require("./ReviewModelUse");
|
|
18
66
|
const connectorCadence_1 = require("./connectorCadence");
|
|
19
67
|
/** How long a fetched profile is cached before the connector re-checks for a new alert. */
|
|
20
68
|
const DEFAULT_PROFILE_TTL_MS = 5 * 60 * 1000;
|
|
@@ -302,6 +350,15 @@ class LocalSensingClient {
|
|
|
302
350
|
constructor(localProvider, uploadClient, options = {}) {
|
|
303
351
|
this.localProvider = localProvider;
|
|
304
352
|
this.uploadClient = uploadClient;
|
|
353
|
+
this.cachedDetectorCounts = false;
|
|
354
|
+
this.cachedMixedScopedCounts = false;
|
|
355
|
+
this.cachedMixedFrameCounts = false;
|
|
356
|
+
this.cachedReviewPolicy = null;
|
|
357
|
+
this.cachedFieldContract = null;
|
|
358
|
+
this.cachedModelSelection = null;
|
|
359
|
+
this.cachedDeclaredProfileReview = false;
|
|
360
|
+
this.cachedVlmFields = [];
|
|
361
|
+
this.cachedFocusRegionsValid = true;
|
|
305
362
|
/**
|
|
306
363
|
* Fields whose ABSENT reading an operator route on this screen fires on, from the
|
|
307
364
|
* deployed profile (see SensingProfileClient.absenceWatchedFields). Refreshed on the
|
|
@@ -411,6 +468,9 @@ class LocalSensingClient {
|
|
|
411
468
|
* profile. See fieldSetForProfileType.
|
|
412
469
|
*/
|
|
413
470
|
this.activeFieldSet = 'audience';
|
|
471
|
+
this.retainedApiBaseUrl = options.retainedApiBaseUrl;
|
|
472
|
+
if (this.retainedApiBaseUrl !== undefined)
|
|
473
|
+
(0, continuousReview_1.assertRetainedReviewReceiver)(this.getEndpoint(), this.retainedApiBaseUrl);
|
|
414
474
|
this.profileClient = options.profileClient;
|
|
415
475
|
this.profileTtlMs = options.profileTtlMs ?? DEFAULT_PROFILE_TTL_MS;
|
|
416
476
|
this.baseSchema = options.schema ?? (0, localInferenceSchema_1.buildLocalSensingSchema)();
|
|
@@ -418,7 +478,34 @@ class LocalSensingClient {
|
|
|
418
478
|
this.cachedSchema = this.baseSchema;
|
|
419
479
|
this.cachedPrompt = this.basePrompt;
|
|
420
480
|
this.attachEvidenceFrame = options.attachEvidenceFrame !== false;
|
|
481
|
+
this.detectorCountsEnabled = options.detectorCounts === true;
|
|
482
|
+
if (this.detectorCountsEnabled)
|
|
483
|
+
this.assertLocalCountReceiver();
|
|
484
|
+
this.scopedVlmReviewEnabled = options.scopedVlmReview === true;
|
|
485
|
+
if (this.scopedVlmReviewEnabled)
|
|
486
|
+
this.assertLocalScopedReceiver();
|
|
487
|
+
this.overlapMixedReviewEnabled = options.overlapMixedReview === true;
|
|
488
|
+
if (this.overlapMixedReviewEnabled)
|
|
489
|
+
this.assertLocalScopedReceiver();
|
|
490
|
+
this.declaredProfileReviewEnabled = options.declaredProfileReview === true;
|
|
491
|
+
this.websiteManagedProfiles = options.websiteManagedProfiles === true;
|
|
492
|
+
if (this.declaredProfileReviewEnabled) {
|
|
493
|
+
this.assertLocalScopedReceiver();
|
|
494
|
+
if (!this.scopedVlmReviewEnabled)
|
|
495
|
+
throw Error('Declared profile review requires scoped review');
|
|
496
|
+
}
|
|
421
497
|
this.producerOptions = options.detectorFieldProducerOptions ?? {};
|
|
498
|
+
this.imageClassifierProducer = options.imageClassifierFieldProducer;
|
|
499
|
+
if (this.imageClassifierProducer)
|
|
500
|
+
this.assertLocalScopedReceiver();
|
|
501
|
+
if (options.rfDetrSourceRuntime) {
|
|
502
|
+
this.assertLocalScopedReceiver();
|
|
503
|
+
const pin = options.rfDetrSourceRuntime;
|
|
504
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(pin.modelId) || !/^[a-f0-9]{64}$/.test(pin.sha256)) {
|
|
505
|
+
throw Error('Invalid RF-DETR source-runtime binding');
|
|
506
|
+
}
|
|
507
|
+
this.rfDetrSourceRuntime = Object.freeze({ modelId: pin.modelId, sha256: pin.sha256 });
|
|
508
|
+
}
|
|
422
509
|
// `null` is an explicit OFF and must not be confused with `undefined`,
|
|
423
510
|
// which is "build the default one". A deployment without the detector
|
|
424
511
|
// service is the former; every shipping connector is the latter.
|
|
@@ -462,7 +549,11 @@ class LocalSensingClient {
|
|
|
462
549
|
const profile = await this.profileClient.fetchProfile();
|
|
463
550
|
if (profile === null)
|
|
464
551
|
return; // transient error — keep last-good schema
|
|
552
|
+
this.reviewProfileState = profile.profileState;
|
|
465
553
|
this.activeProfileName = profile.profileName;
|
|
554
|
+
this.cachedReviewPolicy = (0, ReviewPriority_1.parseReviewPolicy)(profile.reviewPolicy);
|
|
555
|
+
this.cachedFieldContract = profile.retainedFieldContract ?? null;
|
|
556
|
+
this.cachedModelSelection = (0, RuntimeModelSelection_1.parseModelSelection)(profile.modelSelection);
|
|
466
557
|
this.cachedAbsenceWatchedFields = new Set(profile.absenceWatchedFields || []);
|
|
467
558
|
const fieldSet = fieldSetForProfileType(profile.profileType);
|
|
468
559
|
this.activeFieldSet = fieldSet;
|
|
@@ -472,11 +563,22 @@ class LocalSensingClient {
|
|
|
472
563
|
// TypeError on the path to every alert. A field with no consumer is not
|
|
473
564
|
// known to be safe — it is only known to be unread.
|
|
474
565
|
this.cachedProducerFields = profile.producerFields ?? [];
|
|
566
|
+
this.cachedDetectorCounts = this.detectorCountsEnabled && profile.detectorCountEligible === true;
|
|
567
|
+
this.cachedMixedFrameCounts = this.detectorCountsEnabled && this.scopedVlmReviewEnabled
|
|
568
|
+
&& profile.mixedFrameCountEligible === true && profile.mixedScopedCountEligible !== true;
|
|
569
|
+
this.cachedMixedScopedCounts = this.detectorCountsEnabled && this.scopedVlmReviewEnabled
|
|
570
|
+
&& (profile.mixedScopedCountEligible === true || this.cachedMixedFrameCounts);
|
|
475
571
|
// The geometry half of the same partition. `?? new Map()` for the reason the
|
|
476
572
|
// line above carries `?? []`: this assignment can outlive a profile object
|
|
477
573
|
// that predates the key, and an undefined map is a TypeError on the path to
|
|
478
574
|
// every alert the first time anything reads it.
|
|
479
575
|
this.cachedAlertFocusRegions = profile.alertFocusRegions ?? new Map();
|
|
576
|
+
this.cachedFocusRegionsValid = profile.alertFocusRegionsValid !== false;
|
|
577
|
+
this.cachedVlmFields = this.usesWebsiteProfile()
|
|
578
|
+
? profile.vlmFields.filter(field => field.type !== 'hidden') : profile.vlmFields;
|
|
579
|
+
this.cachedDeclaredProfileReview = this.declaredProfileReviewEnabled && fieldSet === 'operator'
|
|
580
|
+
&& !this.cachedProducerFields.length && profile.vlmFields.length > 0
|
|
581
|
+
&& this.cachedModelSelection !== null;
|
|
480
582
|
// Rebuilt only on a real change, and NEVER when the producer was injected —
|
|
481
583
|
// a test's stub must not be silently replaced by a real one the moment a
|
|
482
584
|
// profile arrives.
|
|
@@ -516,6 +618,45 @@ class LocalSensingClient {
|
|
|
516
618
|
this.cachedSchema = this.baseSchema;
|
|
517
619
|
this.cachedPrompt = this.basePrompt;
|
|
518
620
|
}
|
|
621
|
+
if (this.cachedDetectorCounts) {
|
|
622
|
+
// A complete counting observation has no VLM certainty score. Do not invent
|
|
623
|
+
// confidence from a box score, especially when the detector sees zero boxes.
|
|
624
|
+
this.cachedSchema = {
|
|
625
|
+
type: 'object', additionalProperties: false,
|
|
626
|
+
properties: Object.fromEntries([
|
|
627
|
+
...this.cachedProducerFields.map(f => [f.field, { type: f.type === 'boolean' ? 'boolean' : 'integer' }]),
|
|
628
|
+
['people_present', { type: 'boolean' }],
|
|
629
|
+
]),
|
|
630
|
+
required: [...this.cachedProducerFields.map(f => f.field), 'people_present'],
|
|
631
|
+
};
|
|
632
|
+
this.cachedPrompt = 'RF-DETR person counts; people_present is derived from whole-frame person_count > 0. VLM confidence is not measured.';
|
|
633
|
+
}
|
|
634
|
+
if (this.cachedMixedScopedCounts) {
|
|
635
|
+
// Only the explicitly declared activity questions reach Gemma. RF-DETR owns
|
|
636
|
+
// person_count, people_present is derived, and global confidence is unmeasured.
|
|
637
|
+
const properties = this.cachedSchema.properties;
|
|
638
|
+
this.cachedSchema = { type: 'object', additionalProperties: false,
|
|
639
|
+
properties: Object.fromEntries(profile.vlmFields.map(field => [field.field, properties[field.field]])),
|
|
640
|
+
required: profile.vlmFields.map(field => field.field) };
|
|
641
|
+
}
|
|
642
|
+
if (this.cachedDeclaredProfileReview) {
|
|
643
|
+
// Preserve every declared question (including explicitly requested presence).
|
|
644
|
+
// This is a separate captured method, not permission to shorten old evidence.
|
|
645
|
+
const properties = this.cachedSchema.properties;
|
|
646
|
+
this.cachedSchema = { type: 'object', additionalProperties: false,
|
|
647
|
+
properties: Object.fromEntries(profile.vlmFields.map(field => [field.field, properties[field.field]])),
|
|
648
|
+
required: profile.vlmFields.map(field => field.field) };
|
|
649
|
+
this.cachedPrompt = 'Review only the captured operator fields in their assigned camera areas.';
|
|
650
|
+
}
|
|
651
|
+
if (this.usesWebsiteProfile()) {
|
|
652
|
+
// Strict website selection already owns the entire partition. Do not add
|
|
653
|
+
// generic presence/count/confidence questions back into its VLM half.
|
|
654
|
+
const properties = this.cachedSchema.properties;
|
|
655
|
+
this.cachedSchema = { type: 'object', additionalProperties: false,
|
|
656
|
+
properties: Object.fromEntries(this.cachedVlmFields.map(field => [field.field, properties[field.field]])),
|
|
657
|
+
required: this.cachedVlmFields.map(field => field.field) };
|
|
658
|
+
this.cachedPrompt = 'Review only the captured website fields in their assigned camera areas.';
|
|
659
|
+
}
|
|
519
660
|
}
|
|
520
661
|
/**
|
|
521
662
|
* What the on-device runtime resolved about this host's accelerator (which
|
|
@@ -565,168 +706,430 @@ class LocalSensingClient {
|
|
|
565
706
|
getObservedAnalyzeMs() {
|
|
566
707
|
return this.analyzeCost.medianMs();
|
|
567
708
|
}
|
|
568
|
-
|
|
709
|
+
/** Resolve once before LOOK starts; later refreshes never hold up capture. */
|
|
710
|
+
async prepareReview() {
|
|
569
711
|
await this.refreshProfileIfStale();
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
};
|
|
580
|
-
|
|
581
|
-
|
|
712
|
+
}
|
|
713
|
+
/** Only an explicitly verified inactive profile pauses new model work. */
|
|
714
|
+
isReviewActive() {
|
|
715
|
+
return this.reviewProfileState !== 'inactive';
|
|
716
|
+
}
|
|
717
|
+
usesWebsiteProfile() {
|
|
718
|
+
return this.websiteManagedProfiles && this.reviewProfileState === 'active';
|
|
719
|
+
}
|
|
720
|
+
captureReviewContext() {
|
|
721
|
+
void this.prepareReview().catch(() => { });
|
|
722
|
+
const context = JSON.parse(JSON.stringify({
|
|
723
|
+
version: 1,
|
|
724
|
+
...(this.reviewProfileState ? { profileState: this.reviewProfileState } : {}),
|
|
725
|
+
schema: this.cachedSchema,
|
|
726
|
+
...(this.cachedReviewPolicy ? { reviewPolicy: this.cachedReviewPolicy } : {}),
|
|
727
|
+
...(this.cachedFieldContract ? { retainedFieldContract: this.cachedFieldContract } : {}),
|
|
728
|
+
...(this.cachedModelSelection ? { modelSelection: this.cachedModelSelection } : {}),
|
|
729
|
+
prompt: this.cachedPrompt,
|
|
730
|
+
absenceWatchedFields: [...this.cachedAbsenceWatchedFields],
|
|
731
|
+
producerFields: this.cachedProducerFields,
|
|
732
|
+
regions: [...this.cachedAlertFocusRegions],
|
|
733
|
+
// The API advertises operating points from every executable grant. This
|
|
734
|
+
// complete mixed contract owns only person_count; retain exactly its point.
|
|
735
|
+
operatingPoints: this.cachedMixedScopedCounts
|
|
736
|
+
? [['person', this.cachedProducerOperatingPoints.get('person')]]
|
|
737
|
+
: [...this.cachedProducerOperatingPoints],
|
|
738
|
+
...(this.cachedDetectorCounts ? { detectorCounts: true } : {}),
|
|
739
|
+
...((this.scopedVlmReviewEnabled || this.usesWebsiteProfile()) && !this.cachedDetectorCounts && this.activeFieldSet === 'operator'
|
|
740
|
+
&& (this.usesWebsiteProfile() && this.cachedVlmFields.length || this.cachedDeclaredProfileReview
|
|
741
|
+
|| this.cachedMixedFrameCounts || this.cachedAlertFocusRegions.size || !this.cachedFocusRegionsValid) ? { scopedVlmReview: {
|
|
742
|
+
version: 1, fieldSet: 'operator', fields: this.cachedVlmFields, regionsValid: this.cachedFocusRegionsValid,
|
|
743
|
+
...(this.usesWebsiteProfile() ? { declaredVlmFieldsOnly: true }
|
|
744
|
+
: this.cachedDeclaredProfileReview ? { declaredFieldsOnly: true } : {}),
|
|
745
|
+
...(this.cachedMixedScopedCounts ? { personCounts: {
|
|
746
|
+
version: 1, modelId: 'rfdetr_medium', minConfidence: this.cachedProducerOperatingPoints.get('person'),
|
|
747
|
+
...(this.cachedMixedFrameCounts ? { scopePolicy: 'declared_regions_v2' } : {}),
|
|
748
|
+
}, ...(this.overlapMixedReviewEnabled ? { countExecution: 'overlap_v1' } : {}) } : {}),
|
|
749
|
+
} } : {}),
|
|
750
|
+
}));
|
|
751
|
+
if (this.overlapMixedReviewEnabled && !context.detectorCounts && !context.scopedVlmReview
|
|
752
|
+
&& context.producerFields.length && context.modelSelection && !('invalid' in context.modelSelection)
|
|
753
|
+
&& context.modelSelection.models.some(m => m.format === 'onnx')
|
|
754
|
+
&& context.modelSelection.models.some(m => m.format === 'litert-lm')) {
|
|
755
|
+
context.mixedModelExecution = 'overlap_v1';
|
|
756
|
+
}
|
|
757
|
+
return context;
|
|
758
|
+
}
|
|
759
|
+
/** Admission follows the saved profile, never the camera's current setting. */
|
|
760
|
+
getReviewModelUse(context) {
|
|
761
|
+
return (0, ReviewModelUse_1.retainedReviewModelUse)(context);
|
|
762
|
+
}
|
|
763
|
+
/** Finish and durably checkpoint inference without starting a network upload. */
|
|
764
|
+
async reviewCandidate(request) {
|
|
765
|
+
if (!request.checkpointAnalysis)
|
|
766
|
+
throw new Error('Independent review requires a durable checkpoint');
|
|
767
|
+
await this.prepareAnalysis(request);
|
|
768
|
+
}
|
|
769
|
+
/** Isolated review-only rehearsal. Sparse results never enter the normal upload envelope. */
|
|
770
|
+
async reviewProfileWork(request, work, checkpoint) {
|
|
771
|
+
const url = new URL(this.getEndpoint());
|
|
772
|
+
if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) ||
|
|
773
|
+
!request.evidenceId || request.capturedAt === undefined || !request.reviewContext || typeof checkpoint !== 'function') {
|
|
774
|
+
throw Error('Profile work requires isolated durable evidence');
|
|
775
|
+
}
|
|
776
|
+
const context = request.reviewContext;
|
|
777
|
+
const selection = (0, RuntimeModelSelection_1.retainedModelSelection)(context, !!this.imageClassifierProducer, !!this.rfDetrSourceRuntime);
|
|
778
|
+
const sourcePin = selection?.models.find(model => model.format === 'safetensors-rfdetr');
|
|
779
|
+
if (sourcePin && (!this.rfDetrSourceRuntime || selection.models.length !== 1
|
|
780
|
+
|| sourcePin.modelId !== this.rfDetrSourceRuntime.modelId || sourcePin.sha256 !== this.rfDetrSourceRuntime.sha256)) {
|
|
781
|
+
throw Error('RF-DETR source comparison requires its own exactly bound arm');
|
|
782
|
+
}
|
|
783
|
+
const classifierPin = selection?.models.find(model => model.format === 'tflite-image-classifier');
|
|
784
|
+
if (classifierPin) {
|
|
785
|
+
if (!this.imageClassifierProducer || selection.models.length !== 1) {
|
|
786
|
+
throw Error('Image classifier comparison requires its own explicitly injected arm');
|
|
787
|
+
}
|
|
788
|
+
this.imageClassifierProducer.assertContext(context, classifierPin);
|
|
789
|
+
}
|
|
790
|
+
const vlmPin = selection?.models.find(model => model.format === 'litert-lm');
|
|
791
|
+
const detectorPins = selection ? new Map(selection.models.filter(model => model.format === 'onnx' || model.format === 'safetensors-rfdetr')
|
|
792
|
+
.map(model => [model.modelId, model.sha256])) : undefined;
|
|
793
|
+
const regions = new Map(context.regions ?? []);
|
|
794
|
+
const producer = !this.producerInjected ? new DetectorFieldProducer_1.DetectorFieldProducer({ ...this.producerOptions,
|
|
795
|
+
classThresholds: new Map(context.operatingPoints ?? []), regionForField: field => regions.get(field) ?? null }) : this.producer;
|
|
796
|
+
const result = await (0, ProfileReviewWork_1.reviewProfileBatch)({ jpeg: request.jpeg, evidenceId: request.evidenceId,
|
|
797
|
+
capturedAtMs: request.capturedAt, context, work,
|
|
798
|
+
analyze: async (r) => {
|
|
799
|
+
if (selection && !vlmPin)
|
|
800
|
+
throw Error('Retained work has no selected language model');
|
|
801
|
+
return repairLowConfidencePresenceContradiction(await this.localProvider.analyze({ ...r,
|
|
802
|
+
...(vlmPin ? { expectedModel: vlmPin } : {}) }));
|
|
803
|
+
},
|
|
804
|
+
produce: fields => classifierPin
|
|
805
|
+
? this.imageClassifierProducer.produce(request.jpeg, fields, classifierPin)
|
|
806
|
+
: producer ? producer.produce(request.jpeg, fields, detectorPins) : Promise.resolve({ produced: [], withheld: [] }) });
|
|
807
|
+
checkpoint(result);
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
810
|
+
async prepareAnalysis(request) {
|
|
811
|
+
if (!request.reviewContext && !request.cachedAnalysis)
|
|
812
|
+
await this.refreshProfileIfStale();
|
|
813
|
+
const context = request.reviewContext;
|
|
814
|
+
if (!context && this.cachedModelSelection)
|
|
815
|
+
throw Error('Selected model review requires retained evidence');
|
|
816
|
+
if (context && (context.version !== 1 || !context.schema || typeof context.prompt !== 'string' ||
|
|
817
|
+
!Array.isArray(context.producerFields) || !Array.isArray(context.regions) ||
|
|
818
|
+
!Array.isArray(context.operatingPoints) || !Array.isArray(context.absenceWatchedFields)
|
|
819
|
+
|| (context.detectorCounts !== undefined && context.detectorCounts !== true))) {
|
|
820
|
+
throw new Error('Invalid retained sensing profile');
|
|
821
|
+
}
|
|
582
822
|
const capturedAtMs = request.capturedAt ?? Date.now();
|
|
583
|
-
const evidenceId = request.evidenceId || `ev_${(0, crypto_1.randomUUID)()}`;
|
|
584
823
|
const evidenceSha256 = (0, crypto_1.createHash)('sha256').update(request.jpeg).digest('hex');
|
|
585
|
-
let
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
structured = await this.localProvider.analyze({
|
|
592
|
-
jpeg: request.jpeg,
|
|
593
|
-
schema: this.cachedSchema,
|
|
594
|
-
prompt: this.cachedPrompt,
|
|
595
|
-
});
|
|
824
|
+
let saved = request.cachedAnalysis;
|
|
825
|
+
const detectorCounts = context?.detectorCounts === true;
|
|
826
|
+
const scopedReview = context?.scopedVlmReview;
|
|
827
|
+
const capturedPolicy = (0, ReviewPriority_1.parseReviewPolicy)(context?.reviewPolicy);
|
|
828
|
+
if (capturedPolicy && 'invalid' in capturedPolicy) {
|
|
829
|
+
throw Error('Invalid retained profile review policy; evidence retained for diagnosis');
|
|
596
830
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
field
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
831
|
+
const requiredFields = (0, RetainedFieldContract_1.readFieldContract)(context?.retainedFieldContract);
|
|
832
|
+
const selection = (0, RuntimeModelSelection_1.retainedModelSelection)(context);
|
|
833
|
+
if (saved?.runtimeSelectionSha256 !== undefined && !selection)
|
|
834
|
+
throw Error('Saved model-verified result requires its retained selection');
|
|
835
|
+
const vlmPin = selection?.models.find(model => model.format === 'litert-lm');
|
|
836
|
+
const detectorPins = selection ? new Map(selection.models.filter(model => model.format === 'onnx')
|
|
837
|
+
.map(model => [model.modelId, model.sha256])) : undefined;
|
|
838
|
+
if (context?.mixedModelExecution !== undefined) {
|
|
839
|
+
this.assertLocalScopedReceiver();
|
|
840
|
+
if (context.mixedModelExecution !== 'overlap_v1' || detectorCounts || scopedReview
|
|
841
|
+
|| !vlmPin || !detectorPins?.size || !context.producerFields.length
|
|
842
|
+
|| !request.evidenceId || !Number.isFinite(request.capturedAt)
|
|
843
|
+
|| !Number.isFinite(request.reviewWindowMs) || !request.checkpointAnalysis) {
|
|
844
|
+
throw Error('Overlapping paired review requires its complete retained contract');
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (saved && selection && saved.runtimeSelectionSha256 !== (0, ProfileReviewWork_1.reviewContextHash)(context)) {
|
|
848
|
+
throw Error('Saved result does not match retained model selection');
|
|
849
|
+
}
|
|
850
|
+
const mixedCounts = scopedReview?.personCounts !== undefined;
|
|
851
|
+
if (scopedReview?.declaredVlmFieldsOnly !== undefined) {
|
|
852
|
+
const vlmFields = requiredFields?.filter(field => field.modelId === undefined);
|
|
853
|
+
if (context?.profileState !== 'active' || !selection || !vlmPin || !vlmFields
|
|
854
|
+
|| !Array.isArray(scopedReview.fields) || scopedReview.fields.length !== vlmFields.length
|
|
855
|
+
|| vlmFields.some(field => !scopedReview.fields.some(f => f.field === field.field && f.type === field.type))
|
|
856
|
+
|| context.absenceWatchedFields.some(field => !requiredFields.some(f => f.field === field))) {
|
|
857
|
+
throw Error('Website review requires complete captured ownership and absence conditions');
|
|
858
|
+
}
|
|
859
|
+
(0, ScopedVlmReview_1.planScopedVlmReview)(context.schema, scopedReview, context.regions, context.producerFields);
|
|
860
|
+
}
|
|
861
|
+
if (scopedReview?.declaredFieldsOnly !== undefined) {
|
|
862
|
+
if (!selection || !vlmPin || selection.models.length !== 1
|
|
863
|
+
|| !Array.isArray(scopedReview.fields) || !requiredFields
|
|
864
|
+
|| scopedReview.fields.length !== requiredFields.length
|
|
865
|
+
|| requiredFields.some(field => !scopedReview.fields.some(f => f.field === field.field && f.type === field.type))
|
|
866
|
+
|| context.absenceWatchedFields.some(field => !scopedReview.fields.some(f => f.field === field))) {
|
|
867
|
+
throw Error('Declared review requires complete pinned VLM ownership and absence conditions');
|
|
868
|
+
}
|
|
869
|
+
// Revalidate on cached delivery too; an edited snapshot must never drop a question.
|
|
870
|
+
(0, ScopedVlmReview_1.planScopedVlmReview)(context.schema, scopedReview, context.regions, context.producerFields);
|
|
871
|
+
}
|
|
872
|
+
const scopedMethod = mixedCounts ? 'scoped_detector_counts_v1' : 'scoped_vlm_v1';
|
|
873
|
+
if (scopedReview || saved?.analysisMethod === 'scoped_vlm_v1' || saved?.analysisMethod === 'scoped_detector_counts_v1') {
|
|
874
|
+
this.assertLocalScopedReceiver();
|
|
875
|
+
if (!scopedReview || detectorCounts || !request.evidenceId || !Number.isFinite(request.capturedAt)
|
|
876
|
+
|| !Number.isFinite(request.reviewWindowMs) || !request.checkpointAnalysis
|
|
877
|
+
|| !saved && !request.checkpointScopedReview) {
|
|
878
|
+
throw Error('Scoped review requires its retained profile and durable evidence');
|
|
879
|
+
}
|
|
880
|
+
if (mixedCounts) {
|
|
881
|
+
(0, ScopedVlmReview_1.planScopedVlmReview)(context.schema, scopedReview, context.regions, context.producerFields);
|
|
882
|
+
if (context.operatingPoints.length !== 1 || context.operatingPoints[0][0] !== 'person'
|
|
883
|
+
|| context.operatingPoints[0][1] !== scopedReview.personCounts.minConfidence
|
|
884
|
+
|| context.absenceWatchedFields.some(field => !['person_count', 'people_present',
|
|
885
|
+
...scopedReview.fields.map(f => f.field)].includes(field))) {
|
|
886
|
+
throw Error('Mixed review requires the retained threshold and complete field ownership');
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (!context && this.scopedVlmReviewEnabled)
|
|
891
|
+
throw Error('Scoped review requires a retained profile');
|
|
892
|
+
if (!context && this.cachedDetectorCounts)
|
|
893
|
+
throw Error('Detector counts require a retained profile and durable evidence');
|
|
894
|
+
// Pin the captured choice across profile changes/restarts. Neither a new
|
|
895
|
+
// profile nor a saved answer can send this experimental contract to production.
|
|
896
|
+
if (detectorCounts || saved?.analysisMethod === 'detector_counts_v1') {
|
|
897
|
+
this.assertLocalCountReceiver();
|
|
898
|
+
if (!context || !detectorCounts || !request.evidenceId || !Number.isFinite(request.capturedAt)
|
|
899
|
+
|| !Number.isFinite(request.reviewWindowMs) || !request.checkpointAnalysis
|
|
900
|
+
|| !(0, detectorCountReview_1.isPersonCountContract)(context.producerFields, new Map(context.regions))) {
|
|
901
|
+
throw Error('Detector counts require a complete retained profile and durable evidence');
|
|
902
|
+
}
|
|
903
|
+
const properties = context.schema.properties;
|
|
904
|
+
const required = context.schema.required;
|
|
905
|
+
const keys = [...context.producerFields.map(f => f.field), 'people_present'];
|
|
906
|
+
if (!properties || Object.keys(properties).length !== keys.length
|
|
907
|
+
|| !Array.isArray(required) || required.length !== keys.length || new Set(required).size !== keys.length
|
|
908
|
+
|| keys.some(key => !required.includes(key)) || properties.people_present?.type !== 'boolean'
|
|
909
|
+
|| context.producerFields.some(f => properties[f.field]?.type !== (f.type === 'boolean' ? 'boolean' : 'integer'))) {
|
|
910
|
+
throw Error('Detector counts require a complete retained count schema');
|
|
640
911
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
912
|
+
if (context.regions.length && !(0, jpegFrameSize_1.readJpegFrameSize)(request.jpeg)) {
|
|
913
|
+
throw Error('Detector count lasso requires readable image dimensions; retain for retry');
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (saved && (saved.version !== 1 || saved.evidenceSha256 !== evidenceSha256 ||
|
|
917
|
+
!saved.structured || Array.isArray(saved.structured) || typeof saved.structured !== 'object' ||
|
|
918
|
+
typeof saved.promptText !== 'string' || typeof saved.hasSignal !== 'boolean' ||
|
|
919
|
+
!Number.isFinite(saved.analyzeMs) || !Number.isFinite(saved.reviewedAtMs)
|
|
920
|
+
|| (saved.analysisMethod !== undefined && saved.analysisMethod !== 'detector_counts_v1'
|
|
921
|
+
&& saved.analysisMethod !== 'scoped_vlm_v1' && saved.analysisMethod !== 'scoped_detector_counts_v1'))) {
|
|
922
|
+
throw new Error('Invalid saved model result');
|
|
923
|
+
}
|
|
924
|
+
if (saved && (detectorCounts !== (saved.analysisMethod === 'detector_counts_v1'))) {
|
|
925
|
+
throw Error('Saved result does not match retained review method');
|
|
926
|
+
}
|
|
927
|
+
if (saved && (!!scopedReview !== (saved.analysisMethod === 'scoped_vlm_v1' || saved.analysisMethod === 'scoped_detector_counts_v1')
|
|
928
|
+
|| scopedReview && saved.analysisMethod !== scopedMethod
|
|
929
|
+
|| scopedReview && (!Array.isArray(saved.scopeReviews) || !saved.scopeReviews.length
|
|
930
|
+
|| saved.scopeContextSha256 !== (0, ProfileReviewWork_1.reviewContextHash)(context)))) {
|
|
931
|
+
throw Error('Saved result does not match retained scoped review method');
|
|
932
|
+
}
|
|
933
|
+
if (saved && mixedCounts && (!saved.hasSignal || !saved.detectorProduced
|
|
934
|
+
|| Object.keys(saved.detectorProduced).length !== 1 || saved.detectorProduced.person_count !== 'rfdetr_medium'
|
|
935
|
+
|| Object.keys(saved.structured).length !== scopedReview.fields.length + 2
|
|
936
|
+
|| !Number.isSafeInteger(saved.structured.person_count) || saved.structured.person_count < 0
|
|
937
|
+
|| saved.structured.people_present !== (saved.structured.person_count > 0)
|
|
938
|
+
|| scopedReview.fields.some(field => typeof saved.structured[field.field] !== 'boolean'))) {
|
|
939
|
+
throw Error('Invalid saved mixed count and behavior result');
|
|
940
|
+
}
|
|
941
|
+
if (saved && detectorCounts) {
|
|
942
|
+
const fields = context.producerFields;
|
|
943
|
+
if (!saved.hasSignal || !saved.detectorProduced || Object.keys(saved.detectorProduced).length !== fields.length
|
|
944
|
+
|| Object.keys(saved.structured).length !== fields.length + 1
|
|
945
|
+
|| saved.structured.people_present !== (saved.structured.person_count > 0)
|
|
946
|
+
|| fields.some(f => saved.detectorProduced[f.field] !== f.modelId
|
|
947
|
+
|| (f.type === 'boolean' ? typeof saved.structured[f.field] !== 'boolean'
|
|
948
|
+
: !Number.isSafeInteger(saved.structured[f.field]) || saved.structured[f.field] < 0))) {
|
|
949
|
+
throw Error('Invalid saved detector count result');
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (saved?.reuseMethod === 'exact_recent' && (typeof saved.reusedFromEvidenceId !== 'string' || !saved.reusedFromEvidenceId ||
|
|
953
|
+
typeof saved.sourceCapturedAtMs !== 'number' || !Number.isFinite(saved.sourceCapturedAtMs) ||
|
|
954
|
+
typeof saved.sourceReviewedAtMs !== 'number' || !Number.isFinite(saved.sourceReviewedAtMs) ||
|
|
955
|
+
saved.sourceCapturedAtMs > saved.sourceReviewedAtMs || saved.sourceReviewedAtMs > capturedAtMs ||
|
|
956
|
+
capturedAtMs > saved.reviewedAtMs || request.reviewWindowMs === undefined ||
|
|
957
|
+
saved.reviewedAtMs - saved.sourceCapturedAtMs > request.reviewWindowMs)) {
|
|
958
|
+
throw new Error('Invalid recent answer provenance');
|
|
959
|
+
}
|
|
960
|
+
if (!saved) {
|
|
961
|
+
const promptText = context?.prompt ?? this.cachedPrompt;
|
|
962
|
+
const schema = context?.schema ?? this.cachedSchema;
|
|
963
|
+
const fields = context?.producerFields ?? this.cachedProducerFields;
|
|
964
|
+
const absence = context ? new Set(context.absenceWatchedFields) : this.cachedAbsenceWatchedFields;
|
|
965
|
+
const regions = new Map(context?.regions ?? []);
|
|
966
|
+
const producer = context && !this.producerInjected
|
|
967
|
+
? new DetectorFieldProducer_1.DetectorFieldProducer({
|
|
968
|
+
...this.producerOptions,
|
|
969
|
+
classThresholds: new Map(context.operatingPoints),
|
|
970
|
+
regionForField: field => regions.get(field) ?? null,
|
|
971
|
+
})
|
|
972
|
+
: this.producer;
|
|
973
|
+
const startedAt = Date.now();
|
|
974
|
+
let structured = {};
|
|
975
|
+
let scopeReviews;
|
|
976
|
+
let detectorProduced = null;
|
|
977
|
+
const reviewVlm = async () => {
|
|
978
|
+
if (scopedReview) {
|
|
979
|
+
const scoped = await (0, ScopedVlmReview_1.reviewScopedVlm)({ jpeg: request.jpeg, schema, contract: scopedReview,
|
|
980
|
+
...(selection ? { modelSelectionSha256: (0, ProfileReviewWork_1.reviewContextHash)(context) } : {}),
|
|
981
|
+
regions: context.regions, producerFields: fields,
|
|
982
|
+
progress: request.scopedVlmProgress, checkpoint: request.checkpointScopedReview,
|
|
983
|
+
...(mixedCounts ? { produceCounts: async () => {
|
|
984
|
+
if (!producer)
|
|
985
|
+
throw Error('Mixed count producer unavailable; retain for retry');
|
|
986
|
+
const produced = await producer.produce(request.jpeg, [...fields], detectorPins);
|
|
987
|
+
this.producerProduced += produced.produced.length;
|
|
988
|
+
for (const withheld of produced.withheld) {
|
|
989
|
+
this.producerWithheldByReason[withheld.reason] = (this.producerWithheldByReason[withheld.reason] || 0) + 1;
|
|
990
|
+
}
|
|
991
|
+
return produced;
|
|
992
|
+
} } : {}),
|
|
993
|
+
analyze: async (input) => {
|
|
994
|
+
if (selection && !vlmPin)
|
|
995
|
+
throw Error('Retained scope has no selected language model');
|
|
996
|
+
const result = await this.localProvider.analyze({ ...input, ...(vlmPin ? { expectedModel: vlmPin } : {}) });
|
|
997
|
+
this.localInferencesCompleted++;
|
|
998
|
+
return result;
|
|
999
|
+
} });
|
|
1000
|
+
structured = mixedCounts ? scoped.structured : repairLowConfidencePresenceContradiction(scoped.structured);
|
|
1001
|
+
scopeReviews = scoped.traces;
|
|
1002
|
+
if (mixedCounts)
|
|
1003
|
+
detectorProduced = { person_count: 'rfdetr_medium' };
|
|
1004
|
+
}
|
|
1005
|
+
else if (!detectorCounts && (!selection || vlmPin)) {
|
|
1006
|
+
structured = repairLowConfidencePresenceContradiction(await this.localProvider.analyze({
|
|
1007
|
+
jpeg: request.jpeg, schema, prompt: promptText,
|
|
1008
|
+
...(vlmPin ? { expectedModel: vlmPin } : {}),
|
|
1009
|
+
}));
|
|
1010
|
+
this.localInferencesCompleted++;
|
|
1011
|
+
}
|
|
1012
|
+
else if (!producer) {
|
|
1013
|
+
throw Error('Detector count producer unavailable; retain for retry');
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
const reviewDetector = async () => {
|
|
1017
|
+
if (!producer || !fields.length || mixedCounts)
|
|
1018
|
+
return undefined;
|
|
1019
|
+
return producer.produce(request.jpeg, fields.map(f => ({
|
|
1020
|
+
field: f.field, modelId: f.modelId,
|
|
1021
|
+
type: f.type ?? null, derivation: f.derivation ?? null,
|
|
1022
|
+
})), detectorPins);
|
|
1023
|
+
};
|
|
1024
|
+
let produced;
|
|
1025
|
+
if (context?.mixedModelExecution === 'overlap_v1') {
|
|
1026
|
+
// Keep both scheduler reservations until both calls settle, even on failure.
|
|
1027
|
+
// An early rejection must not admit another candidate onto a still-busy model.
|
|
1028
|
+
const [vlmResult, detectorResult] = await Promise.allSettled([reviewVlm(), reviewDetector()]);
|
|
1029
|
+
if (vlmResult.status === 'rejected')
|
|
1030
|
+
throw vlmResult.reason;
|
|
1031
|
+
if (detectorResult.status === 'rejected')
|
|
1032
|
+
throw detectorResult.reason;
|
|
1033
|
+
produced = detectorResult.value;
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
await reviewVlm();
|
|
1037
|
+
produced = await reviewDetector();
|
|
1038
|
+
}
|
|
1039
|
+
if (produced) {
|
|
1040
|
+
this.producerProduced += produced.produced.length;
|
|
1041
|
+
for (const withheld of produced.withheld) {
|
|
1042
|
+
this.producerWithheldByReason[withheld.reason] =
|
|
1043
|
+
(this.producerWithheldByReason[withheld.reason] || 0) + 1;
|
|
1044
|
+
}
|
|
1045
|
+
const { merged, collisions, producedBy } = mergeProducedFields(structured, produced);
|
|
1046
|
+
structured = merged;
|
|
1047
|
+
if (detectorCounts)
|
|
1048
|
+
structured = (0, detectorCountReview_1.completePersonCounts)(produced, fields);
|
|
1049
|
+
detectorProduced = Object.keys(producedBy).length ? producedBy : null;
|
|
647
1050
|
this.producerFieldCollisions += collisions.length;
|
|
648
1051
|
}
|
|
1052
|
+
const analyzeMs = Date.now() - startedAt;
|
|
1053
|
+
this.analyzeCost.record(analyzeMs);
|
|
1054
|
+
saved = {
|
|
1055
|
+
version: 1, structured, detectorProduced, analyzeMs, promptText,
|
|
1056
|
+
reviewedAtMs: Date.now(), evidenceSha256,
|
|
1057
|
+
...(selection ? { runtimeSelectionSha256: (0, ProfileReviewWork_1.reviewContextHash)(context) } : {}),
|
|
1058
|
+
// Zero can satisfy a below-count/absence rule too. Keep its evidence.
|
|
1059
|
+
hasSignal: detectorCounts || mixedCounts || isAlertWorthy(structured, absence),
|
|
1060
|
+
...(detectorCounts ? { analysisMethod: 'detector_counts_v1' } : {}),
|
|
1061
|
+
...(scopedReview ? { analysisMethod: scopedMethod, scopeReviews,
|
|
1062
|
+
scopeContextSha256: (0, ProfileReviewWork_1.reviewContextHash)(context) } : {}),
|
|
1063
|
+
};
|
|
649
1064
|
}
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const
|
|
1065
|
+
// Also retry a previously failed disk commit before any upload. The spool
|
|
1066
|
+
// keeps that answer in memory while disk recovers; inference is not repeated.
|
|
1067
|
+
(0, RetainedFieldContract_1.assertCompleteFields)(requiredFields, saved.structured, saved.detectorProduced);
|
|
1068
|
+
request.checkpointAnalysis?.(saved);
|
|
1069
|
+
return saved;
|
|
1070
|
+
}
|
|
1071
|
+
assertLocalCountReceiver() {
|
|
1072
|
+
(0, continuousReview_1.assertRetainedReviewReceiver)(this.getEndpoint(), this.retainedApiBaseUrl);
|
|
1073
|
+
}
|
|
1074
|
+
assertLocalScopedReceiver() {
|
|
1075
|
+
(0, continuousReview_1.assertRetainedReviewReceiver)(this.getEndpoint(), this.retainedApiBaseUrl);
|
|
1076
|
+
}
|
|
1077
|
+
async analyze(request) {
|
|
1078
|
+
const capturedAtMs = request.capturedAt ?? Date.now();
|
|
1079
|
+
const saved = await this.prepareAnalysis({ ...request, capturedAt: capturedAtMs });
|
|
1080
|
+
const evidenceId = request.evidenceId || `ev_${(0, crypto_1.randomUUID)()}`;
|
|
1081
|
+
const evidenceSha256 = saved.evidenceSha256;
|
|
1082
|
+
const review = request.reviewWindowMs === undefined ? undefined :
|
|
1083
|
+
(0, continuousReview_1.evidenceReview)(capturedAtMs, saved.reviewedAtMs, request.reviewWindowMs);
|
|
1084
|
+
if (review && typeof saved.reusedFromEvidenceId === 'string') {
|
|
1085
|
+
review.reusedFromEvidenceId = saved.reusedFromEvidenceId;
|
|
1086
|
+
}
|
|
1087
|
+
if (review && saved.reuseMethod === 'exact_recent') {
|
|
1088
|
+
review.reuseMethod = 'exact_recent';
|
|
1089
|
+
review.sourceCapturedAtMs = saved.sourceCapturedAtMs;
|
|
1090
|
+
review.sourceReviewedAtMs = saved.sourceReviewedAtMs;
|
|
1091
|
+
}
|
|
1092
|
+
const selection = (0, RuntimeModelSelection_1.retainedModelSelection)(request.reviewContext);
|
|
1093
|
+
const selectedDetectorOnly = selection && selection.models.every(model => model.format === 'onnx')
|
|
1094
|
+
&& saved.analysisMethod === undefined;
|
|
666
1095
|
const result = {
|
|
667
|
-
...structured,
|
|
668
|
-
source: '
|
|
1096
|
+
...saved.structured,
|
|
1097
|
+
source: selectedDetectorOnly || saved.analysisMethod === 'detector_counts_v1' ? 'detector_edge'
|
|
1098
|
+
: saved.analysisMethod === 'scoped_detector_counts_v1' ? 'mixed_edge' : 'vlm_edge',
|
|
669
1099
|
captured_at: new Date(capturedAtMs).toISOString(),
|
|
670
1100
|
};
|
|
671
|
-
const includeFrame = this.attachEvidenceFrame && hasSignal;
|
|
672
1101
|
return this.uploadClient.analyzePreAnalyzed(result, {
|
|
673
1102
|
capturedAt: capturedAtMs,
|
|
674
1103
|
captureMode: request.captureMode,
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
//
|
|
705
|
-
// IT NOW HAS ONE. `earner-misc.js` names `detectorProduced`, and
|
|
706
|
-
// `ingestPreAnalyzedFrame` stamps those fields `local_detector` in
|
|
707
|
-
// `_provenance_map` — which `chipPersistence.fanOutProfileFields` already
|
|
708
|
-
// writes, unchanged, into `observation_field_values.provenance`. So the
|
|
709
|
-
// fact lands at the grain it belongs to, per field per observation, in the
|
|
710
|
-
// per-field provenance column that has existed all along.
|
|
711
|
-
//
|
|
712
|
-
// WHY IT HAD TO SHIP: a merged `forklift_present: true` is byte-identical
|
|
713
|
-
// whether `gh_forklift_combine` counted a detection at its fitted 0.7435 or
|
|
714
|
-
// the VLM wrote it in prose. Without this key, "the false-alarm rate WITH
|
|
715
|
-
// specialists serving" is not a hard measurement — it is an unanswerable
|
|
716
|
-
// one, and a run whose detectors all withheld would report VLM numbers
|
|
717
|
-
// under a specialist label with nothing able to contradict it.
|
|
718
|
-
//
|
|
719
|
-
// OMITTED, never `{}`, when no field was produced: absent means "no
|
|
720
|
-
// specialist answered anything on this frame", and an empty object would
|
|
721
|
-
// make a box with no granted specialist indistinguishable from one whose
|
|
722
|
-
// detector was down — the same absent-vs-negative distinction the withhold
|
|
723
|
-
// rule exists for, one layer up.
|
|
724
|
-
...(detectorProduced ? { detectorProduced } : {}),
|
|
725
|
-
// Forwarded verbatim. `analyzePreAnalyzed` spreads it last with the same
|
|
726
|
-
// override semantics `analyze` documents, so this is one channel end to
|
|
727
|
-
// end rather than a second one shaped like the first.
|
|
728
|
-
...(request.extra ? { extra: request.extra } : {}),
|
|
729
|
-
...(includeFrame ? { jpeg: request.jpeg, evidenceId, evidenceSha256 } : {}),
|
|
1104
|
+
imageWidth: request.imageWidth,
|
|
1105
|
+
imageHeight: request.imageHeight,
|
|
1106
|
+
analyzeMs: saved.analyzeMs,
|
|
1107
|
+
promptText: saved.promptText,
|
|
1108
|
+
...(saved.detectorProduced ? { detectorProduced: saved.detectorProduced } : {}),
|
|
1109
|
+
...((request.extra || review) ? {
|
|
1110
|
+
extra: { ...request.extra, ...(review ? { evidenceReview: review } : {}),
|
|
1111
|
+
...(selectedDetectorOnly ? { selectedDetectorReview: {
|
|
1112
|
+
version: 1, selectionSha256: saved.runtimeSelectionSha256,
|
|
1113
|
+
fields: selection.fieldOwners.map(owner => owner.field), confidence: 'not_measured',
|
|
1114
|
+
} } : {}),
|
|
1115
|
+
...(saved.analysisMethod === 'detector_counts_v1' ? { detectorCountReview: {
|
|
1116
|
+
version: 1, fields: Object.keys(saved.detectorProduced ?? {}),
|
|
1117
|
+
derivedPresenceFrom: 'person_count', confidence: 'not_measured',
|
|
1118
|
+
} } : {}),
|
|
1119
|
+
...(saved.analysisMethod === 'scoped_detector_counts_v1' ? { mixedCountReview: {
|
|
1120
|
+
version: 1, fields: request.reviewContext.scopedVlmReview.fields.map(f => f.field),
|
|
1121
|
+
countModel: 'rfdetr_medium',
|
|
1122
|
+
countMinConfidence: request.reviewContext.scopedVlmReview.personCounts.minConfidence,
|
|
1123
|
+
confidence: 'not_measured', scopeContextSha256: saved.scopeContextSha256,
|
|
1124
|
+
} } : {}),
|
|
1125
|
+
},
|
|
1126
|
+
} : {}),
|
|
1127
|
+
// Retained ingest accepts ownership of the original evidence, including a
|
|
1128
|
+
// completed negative observation. Omitting its JPEG causes endless 400 retries.
|
|
1129
|
+
...(review ? { evidenceId, evidenceSha256 } : {}),
|
|
1130
|
+
...(this.attachEvidenceFrame && (saved.hasSignal || review) ? {
|
|
1131
|
+
jpeg: request.jpeg, evidenceId, evidenceSha256,
|
|
1132
|
+
} : {}),
|
|
730
1133
|
});
|
|
731
1134
|
}
|
|
732
1135
|
}
|
|
@@ -778,6 +1181,60 @@ function resolveMaxStreamSessionsPerSource(requested, warn = (m) => console.warn
|
|
|
778
1181
|
}
|
|
779
1182
|
return effective;
|
|
780
1183
|
}
|
|
1184
|
+
/** Under the connector's data root: one directory per recording feed. */
|
|
1185
|
+
exports.VIDEO_SEGMENTS_DIRNAME = 'video-segments';
|
|
1186
|
+
/**
|
|
1187
|
+
* One stream, one key: scheme, host, port, path and query of an RTSP URL,
|
|
1188
|
+
* WITHOUT its credentials — two feeds pointing at the same channel with
|
|
1189
|
+
* different logins are one camera, and record once.
|
|
1190
|
+
*/
|
|
1191
|
+
function recordingUrlKey(rtspUrl) {
|
|
1192
|
+
if (!rtspUrl)
|
|
1193
|
+
return null;
|
|
1194
|
+
try {
|
|
1195
|
+
const u = new URL(rtspUrl);
|
|
1196
|
+
const port = u.port || (u.protocol === 'rtsps:' ? '322' : u.protocol === 'rtsp:' ? '554' : '');
|
|
1197
|
+
return `${u.protocol}//${u.hostname.toLowerCase()}:${port}${u.pathname}${u.search}`;
|
|
1198
|
+
}
|
|
1199
|
+
catch {
|
|
1200
|
+
return rtspUrl;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* A feed's video directory name: its screen id (stable across restarts and
|
|
1205
|
+
* re-pairs), else a hash of its token — never the token itself.
|
|
1206
|
+
*/
|
|
1207
|
+
function videoFeedKey(feed) {
|
|
1208
|
+
const screen = feed.screenId ? String(feed.screenId).replace(/[^A-Za-z0-9._-]/g, '_') : '';
|
|
1209
|
+
if (screen)
|
|
1210
|
+
return screen;
|
|
1211
|
+
return `token-${(0, crypto_1.createHash)('sha256').update(String(feed.deviceToken)).digest('hex').slice(0, 16)}`;
|
|
1212
|
+
}
|
|
1213
|
+
let lastVideoRecordingPlan = { recordingFeeds: [], sharedUrlFeeds: [], croppedFeeds: [] };
|
|
1214
|
+
/** The plan `buildLocalSensingLoops` last built, for diagnostics. */
|
|
1215
|
+
function getVideoRecordingPlan() {
|
|
1216
|
+
return {
|
|
1217
|
+
recordingFeeds: [...lastVideoRecordingPlan.recordingFeeds],
|
|
1218
|
+
sharedUrlFeeds: [...lastVideoRecordingPlan.sharedUrlFeeds],
|
|
1219
|
+
croppedFeeds: [...lastVideoRecordingPlan.croppedFeeds],
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
function summarizeVideoRecordingPlan(feeds, recordingFeedByUrl) {
|
|
1223
|
+
const recordingFeeds = [...recordingFeedByUrl.values()];
|
|
1224
|
+
const recording = new Set(recordingFeeds);
|
|
1225
|
+
const sharedUrlFeeds = [];
|
|
1226
|
+
const croppedFeeds = [];
|
|
1227
|
+
for (const feed of feeds) {
|
|
1228
|
+
if (recording.has(feed.name))
|
|
1229
|
+
continue;
|
|
1230
|
+
const key = recordingUrlKey(feed.rtspUrl);
|
|
1231
|
+
if (key !== null && recordingFeedByUrl.has(key))
|
|
1232
|
+
sharedUrlFeeds.push(feed.name);
|
|
1233
|
+
else if (feed.crop)
|
|
1234
|
+
croppedFeeds.push(feed.name);
|
|
1235
|
+
}
|
|
1236
|
+
return { recordingFeeds, sharedUrlFeeds, croppedFeeds };
|
|
1237
|
+
}
|
|
781
1238
|
/**
|
|
782
1239
|
* Build per-feed capture loops for LOCAL sensing — one RTSP provider + one
|
|
783
1240
|
* LocalSensingClient (local VLM + preAnalyzed upload) per feed, each under its
|
|
@@ -862,12 +1319,32 @@ function buildLocalSensingLoops(config, deps) {
|
|
|
862
1319
|
* silent clamp — the same standard `formatDeployedCadence` is held to.
|
|
863
1320
|
*/
|
|
864
1321
|
const maxStreamSessionsPerSource = resolveMaxStreamSessionsPerSource(config.cloudSensing?.maxStreamSessionsPerSource);
|
|
1322
|
+
/**
|
|
1323
|
+
* VIDEO — the same persistent session records the camera's own bitstream as
|
|
1324
|
+
* MP4 segments (a stream copy: no decode, no second RTSP session), and a
|
|
1325
|
+
* recorder per feed delivers them into `video_segment`.
|
|
1326
|
+
*
|
|
1327
|
+
* ONE recording per distinct RTSP URL. Mosaic tiles share their source's
|
|
1328
|
+
* URL: the source feed's provider (built crop-less, under the source feed's
|
|
1329
|
+
* own token) records, and every other feed on that URL is skipped and
|
|
1330
|
+
* counted in `getVideoRecordingPlan()`. A cropped per-tile pull never
|
|
1331
|
+
* records — its crop belongs to the look output, and a copy would file the
|
|
1332
|
+
* whole composite under one tile's screen.
|
|
1333
|
+
*
|
|
1334
|
+
* Recording itself is the SERVER's call: the recorder applies the `video`
|
|
1335
|
+
* knobs from this feed's sensing profile, and records nothing until the
|
|
1336
|
+
* server has answered once. Segments live under the connector's data root,
|
|
1337
|
+
* the one directory the Linux unit grants write access to.
|
|
1338
|
+
*/
|
|
1339
|
+
const videoRoot = path.join((0, dataDir_1.resolveDataDir)(config.dataDir), exports.VIDEO_SEGMENTS_DIRNAME);
|
|
1340
|
+
const recordingFeedByUrl = new Map();
|
|
1341
|
+
const attachVideo = deps?.videoRecorderFactory === undefined ? VideoSegmentUploader_1.attachCctvVideoRecorder : deps.videoRecorderFactory;
|
|
865
1342
|
const rtspFactory = deps?.rtspProviderFactory ??
|
|
866
1343
|
((feed) => {
|
|
867
1344
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
868
1345
|
const { RTSPStreamCameraProvider } = require('@trillboards/edge-platform-rtsp');
|
|
869
1346
|
const legacy = (0, legacyRecorderProfile_1.legacyRecorderRtspOptions)(feed.sourceType);
|
|
870
|
-
|
|
1347
|
+
const provider = new RTSPStreamCameraProvider({
|
|
871
1348
|
rtspUrl: feed.rtspUrl,
|
|
872
1349
|
width: feed.width,
|
|
873
1350
|
height: feed.height,
|
|
@@ -903,12 +1380,38 @@ function buildLocalSensingLoops(config, deps) {
|
|
|
903
1380
|
outputFormat: feed.outputFormat ?? 'jpeg',
|
|
904
1381
|
},
|
|
905
1382
|
});
|
|
1383
|
+
const urlKey = recordingUrlKey(feed.rtspUrl);
|
|
1384
|
+
if (attachVideo && !feed.crop && urlKey !== null && !recordingFeedByUrl.has(urlKey)) {
|
|
1385
|
+
// Recording is an addition to a feed, never a condition of it: a
|
|
1386
|
+
// recorder that cannot be built leaves the feed sensing exactly as before.
|
|
1387
|
+
try {
|
|
1388
|
+
attachVideo({
|
|
1389
|
+
provider,
|
|
1390
|
+
dir: path.join(videoRoot, videoFeedKey(feed)),
|
|
1391
|
+
feedName: feed.name,
|
|
1392
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
1393
|
+
deviceToken: feed.deviceToken,
|
|
1394
|
+
// The same cadence the local profile cache refetches on.
|
|
1395
|
+
pollMs: DEFAULT_PROFILE_TTL_MS,
|
|
1396
|
+
...(deps?.httpPoster ? { httpPoster: deps.httpPoster } : {}),
|
|
1397
|
+
});
|
|
1398
|
+
recordingFeedByUrl.set(urlKey, feed.name);
|
|
1399
|
+
}
|
|
1400
|
+
catch (err) {
|
|
1401
|
+
console.warn(`[video] ${feed.name}: recording not attached: ${err instanceof Error ? err.message : String(err)}`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
return provider;
|
|
906
1405
|
});
|
|
907
1406
|
const localProviderFactory = deps?.localProviderFactory ??
|
|
908
1407
|
(() => new LiteRtVlmProvider_1.LiteRtVlmProvider({ litertLmBaseUrl, model, accelerator }));
|
|
1408
|
+
const reviewOptions = config.cloudSensing?.candidateBuffer?.continuousReview;
|
|
1409
|
+
const retainedApiBaseUrl = (0, continuousReview_1.retainedReviewApiBase)(reviewOptions, config.apiBaseUrl);
|
|
909
1410
|
const uploadClientFactory = deps?.uploadClientFactory ??
|
|
910
1411
|
((feed) => new AudienceAnalyzeClient_1.AudienceAnalyzeClient({
|
|
911
|
-
apiBaseUrl: config.
|
|
1412
|
+
apiBaseUrl: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled
|
|
1413
|
+
? retainedApiBaseUrl ?? config.cloudSensing.candidateBuffer.continuousReview.receiverUrl ?? continuousReview_1.DEFAULT_LOCAL_REVIEW_RECEIVER_URL
|
|
1414
|
+
: config.apiBaseUrl,
|
|
912
1415
|
deviceToken: feed.deviceToken,
|
|
913
1416
|
screenId: feed.screenId,
|
|
914
1417
|
imageWidth: feed.width,
|
|
@@ -922,7 +1425,7 @@ function buildLocalSensingLoops(config, deps) {
|
|
|
922
1425
|
// a single shared pull + crops each tile, so local-sense honors mosaic configs
|
|
923
1426
|
// identically to cloud-sense. `sharedSources` lets the VLM attach to the SAME
|
|
924
1427
|
// pull the on-device zoo uses (combined-sense), so one RTSP pull feeds both.
|
|
925
|
-
|
|
1428
|
+
const tiled = (0, CloudSensingManager_1.buildTiledCameras)(feeds, rtspFactory, {
|
|
926
1429
|
sharedSources: deps?.sharedSources,
|
|
927
1430
|
// A MOSAIC TILE MUST NOT SILENTLY KEEP THE OLD CADENCE.
|
|
928
1431
|
//
|
|
@@ -944,12 +1447,34 @@ function buildLocalSensingLoops(config, deps) {
|
|
|
944
1447
|
// pulls remain bounded by `CaptureAdmission` (2 per physical recorder) —
|
|
945
1448
|
// a recorder-scoped bound, which is the one that was doing the protecting.
|
|
946
1449
|
sharedFrameMaxAgeMs: (0, connectorCadence_1.lookIntervalMsForFeedCount)(feeds.length, 'persistent') ?? connectorCadence_1.LOOK_INTERVAL_MS,
|
|
947
|
-
})
|
|
1450
|
+
});
|
|
1451
|
+
// Only the default factory records, so only its build has a plan to report.
|
|
1452
|
+
if (!deps?.rtspProviderFactory) {
|
|
1453
|
+
lastVideoRecordingPlan = summarizeVideoRecordingPlan(feeds, recordingFeedByUrl);
|
|
1454
|
+
if (attachVideo) {
|
|
1455
|
+
const plan = lastVideoRecordingPlan;
|
|
1456
|
+
console.log(`[video] ${plan.recordingFeeds.length} feed(s) can record (one per RTSP URL); ` +
|
|
1457
|
+
`${plan.sharedUrlFeeds.length} share a recorded URL; ${plan.croppedFeeds.length} cropped tile(s) without one. ` +
|
|
1458
|
+
'Recording follows the server\'s video knobs.');
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return tiled.map(({ feed, camera }) => ({
|
|
948
1462
|
name: feed.name,
|
|
949
1463
|
camera,
|
|
950
1464
|
client: new LocalSensingClient(localProviderFactory(feed), uploadClientFactory(feed), {
|
|
1465
|
+
websiteManagedProfiles: deps?.websiteManagedProfiles,
|
|
1466
|
+
retainedApiBaseUrl,
|
|
951
1467
|
profileClient: profileClientFactory(feed),
|
|
952
1468
|
attachEvidenceFrame,
|
|
1469
|
+
detectorFieldProducerOptions: deps?.detectorFieldProducerOptions,
|
|
1470
|
+
detectorCounts: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled === true
|
|
1471
|
+
&& config.cloudSensing.candidateBuffer.continuousReview.detectorCounts === true,
|
|
1472
|
+
scopedVlmReview: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled === true
|
|
1473
|
+
&& config.cloudSensing.candidateBuffer.continuousReview.scopedVlmReview === true,
|
|
1474
|
+
overlapMixedReview: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled === true
|
|
1475
|
+
&& config.cloudSensing.candidateBuffer.continuousReview.overlapMixedReview === true,
|
|
1476
|
+
declaredProfileReview: config.cloudSensing?.candidateBuffer?.continuousReview?.enabled === true
|
|
1477
|
+
&& config.cloudSensing.candidateBuffer.continuousReview.declaredProfileReview === true,
|
|
953
1478
|
}),
|
|
954
1479
|
}));
|
|
955
1480
|
}
|