@blueharford/scrypted-spatial-awareness 0.6.12 → 0.6.14

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/out/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
@@ -138,6 +138,12 @@ export class TrackingEngine {
138
138
  /** Callback for training status updates */
139
139
  private onTrainingStatusUpdate?: (status: TrainingStatusUpdate) => void;
140
140
 
141
+ // ==================== Snapshot Cache ====================
142
+ /** Cached snapshots for tracked objects (for faster notifications) */
143
+ private snapshotCache: Map<GlobalTrackingId, MediaObject> = new Map();
144
+ /** Pending LLM description promises (started when snapshot is captured) */
145
+ private pendingDescriptions: Map<GlobalTrackingId, Promise<SpatialReasoningResult>> = new Map();
146
+
141
147
  constructor(
142
148
  topology: CameraTopology,
143
149
  state: TrackingState,
@@ -437,6 +443,13 @@ export class TrackingEngine {
437
443
  if (lastSighting && lastSighting.cameraId !== sighting.cameraId) {
438
444
  const transitDuration = sighting.timestamp - lastSighting.timestamp;
439
445
 
446
+ // Update cached snapshot from new camera (object is now visible here)
447
+ if (this.config.useLlmDescriptions) {
448
+ this.captureAndCacheSnapshot(tracked.globalId, sighting.cameraId).catch(e => {
449
+ this.console.warn(`[Transition Snapshot] Failed to update snapshot: ${e}`);
450
+ });
451
+ }
452
+
440
453
  // Add journey segment
441
454
  this.state.addJourney(tracked.globalId, {
442
455
  fromCameraId: lastSighting.cameraId,
@@ -528,6 +541,14 @@ export class TrackingEngine {
528
541
  sighting: ObjectSighting,
529
542
  isEntryPoint: boolean
530
543
  ): void {
544
+ // Capture snapshot IMMEDIATELY when object is first detected (don't wait for loitering threshold)
545
+ // This ensures we have a good image while the person/object is still in frame
546
+ if (this.config.useLlmDescriptions) {
547
+ this.captureAndCacheSnapshot(globalId, sighting.cameraId).catch(e => {
548
+ this.console.warn(`[Snapshot] Failed to cache initial snapshot: ${e}`);
549
+ });
550
+ }
551
+
531
552
  // Check after loitering threshold if object is still being tracked
532
553
  setTimeout(async () => {
533
554
  const tracked = this.state.getObject(globalId);
@@ -536,31 +557,28 @@ export class TrackingEngine {
536
557
  // Check if we've already alerted for this object
537
558
  if (this.isInAlertCooldown(globalId)) return;
538
559
 
539
- // Get snapshot for LLM description (if LLM is enabled)
540
- let mediaObject: MediaObject | undefined;
541
- this.console.log(`[Entry Alert] useLlmDescriptions=${this.config.useLlmDescriptions}`);
542
- if (this.config.useLlmDescriptions) {
560
+ // Use prefetched LLM result if available (started when snapshot was captured)
561
+ let spatialResult: SpatialReasoningResult;
562
+ const pendingDescription = this.pendingDescriptions.get(globalId);
563
+
564
+ if (pendingDescription) {
565
+ this.console.log(`[Entry Alert] Using prefetched LLM result for ${globalId.slice(0, 8)}`);
543
566
  try {
544
- const camera = systemManager.getDeviceById<Camera>(sighting.cameraId);
545
- this.console.log(`[Entry Alert] Camera ${sighting.cameraId} has Camera interface: ${camera?.interfaces?.includes(ScryptedInterface.Camera)}`);
546
- if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
547
- mediaObject = await camera.takePicture();
548
- this.console.log(`[Entry Alert] Got snapshot: ${!!mediaObject}`);
549
- }
567
+ spatialResult = await pendingDescription;
568
+ this.console.log(`[Entry Alert] Prefetch result: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
550
569
  } catch (e) {
551
- this.console.warn('[Entry Alert] Failed to get snapshot:', e);
570
+ this.console.warn(`[Entry Alert] Prefetch failed, generating fallback: ${e}`);
571
+ spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId);
552
572
  }
573
+ this.pendingDescriptions.delete(globalId);
574
+ } else {
575
+ // Fallback: generate description now (slower path)
576
+ this.console.log(`[Entry Alert] No prefetch available, generating now`);
577
+ const mediaObject = this.snapshotCache.get(globalId);
578
+ spatialResult = await this.spatialReasoning.generateEntryDescription(tracked, sighting.cameraId, mediaObject);
579
+ this.console.log(`[Entry Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
553
580
  }
554
581
 
555
- // Generate spatial description (now async with LLM support)
556
- this.console.log(`[Entry Alert] Calling generateEntryDescription with mediaObject=${!!mediaObject}`);
557
- const spatialResult = await this.spatialReasoning.generateEntryDescription(
558
- tracked,
559
- sighting.cameraId,
560
- mediaObject
561
- );
562
- this.console.log(`[Entry Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
563
-
564
582
  if (isEntryPoint) {
565
583
  // Entry point - generate property entry alert
566
584
  await this.alertManager.checkAndAlert('property_entry', tracked, {
@@ -592,6 +610,44 @@ export class TrackingEngine {
592
610
  }, this.config.loiteringThreshold);
593
611
  }
594
612
 
613
+ /** Capture and cache a snapshot for a tracked object, and start LLM analysis immediately */
614
+ private async captureAndCacheSnapshot(
615
+ globalId: GlobalTrackingId,
616
+ cameraId: string,
617
+ eventType: 'entry' | 'exit' | 'movement' = 'entry'
618
+ ): Promise<void> {
619
+ try {
620
+ const camera = systemManager.getDeviceById<Camera>(cameraId);
621
+ if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
622
+ const mediaObject = await camera.takePicture();
623
+ if (mediaObject) {
624
+ this.snapshotCache.set(globalId, mediaObject);
625
+ this.console.log(`[Snapshot] Cached snapshot for ${globalId.slice(0, 8)} from ${cameraId}`);
626
+
627
+ // Start LLM analysis immediately in parallel (don't await)
628
+ const tracked = this.state.getObject(globalId);
629
+ if (tracked && this.config.useLlmDescriptions) {
630
+ this.console.log(`[LLM Prefetch] Starting ${eventType} analysis for ${globalId.slice(0, 8)}`);
631
+ const descriptionPromise = eventType === 'exit'
632
+ ? this.spatialReasoning.generateExitDescription(tracked, cameraId, mediaObject)
633
+ : this.spatialReasoning.generateEntryDescription(tracked, cameraId, mediaObject);
634
+
635
+ this.pendingDescriptions.set(globalId, descriptionPromise);
636
+
637
+ // Log when complete (for debugging)
638
+ descriptionPromise.then(result => {
639
+ this.console.log(`[LLM Prefetch] ${eventType} analysis ready for ${globalId.slice(0, 8)}: "${result.description.substring(0, 40)}..."`);
640
+ }).catch(e => {
641
+ this.console.warn(`[LLM Prefetch] Failed for ${globalId.slice(0, 8)}: ${e}`);
642
+ });
643
+ }
644
+ }
645
+ }
646
+ } catch (e) {
647
+ this.console.warn(`[Snapshot] Failed to capture snapshot: ${e}`);
648
+ }
649
+ }
650
+
595
651
  /** Attempt to correlate a sighting with existing tracked objects */
596
652
  private async correlateDetection(
597
653
  sighting: ObjectSighting
@@ -639,40 +695,42 @@ export class TrackingEngine {
639
695
  // Mark as pending and set timer
640
696
  this.state.markPending(tracked.globalId);
641
697
 
698
+ // Capture a fresh snapshot now while object is still visible (before they leave)
699
+ // Also starts LLM analysis immediately in parallel
700
+ if (this.config.useLlmDescriptions) {
701
+ this.captureAndCacheSnapshot(tracked.globalId, sighting.cameraId, 'exit').catch(e => {
702
+ this.console.warn(`[Exit Snapshot] Failed to update snapshot: ${e}`);
703
+ });
704
+ }
705
+
642
706
  // Wait for correlation window before marking as exited
643
707
  const timer = setTimeout(async () => {
644
708
  const current = this.state.getObject(tracked.globalId);
645
709
  if (current && current.state === 'pending') {
646
710
  this.state.markExited(tracked.globalId, sighting.cameraId, sighting.cameraName);
647
711
 
648
- // Get snapshot for LLM description (if LLM is enabled)
649
- let mediaObject: MediaObject | undefined;
650
- this.console.log(`[Exit Alert] useLlmDescriptions=${this.config.useLlmDescriptions}`);
651
- if (this.config.useLlmDescriptions) {
712
+ // Use prefetched LLM result if available (started when exit was first detected)
713
+ let spatialResult: SpatialReasoningResult;
714
+ const pendingDescription = this.pendingDescriptions.get(tracked.globalId);
715
+
716
+ if (pendingDescription) {
717
+ this.console.log(`[Exit Alert] Using prefetched LLM result for ${tracked.globalId.slice(0, 8)}`);
652
718
  try {
653
- const camera = systemManager.getDeviceById<Camera>(sighting.cameraId);
654
- this.console.log(`[Exit Alert] Camera ${sighting.cameraId} has Camera interface: ${camera?.interfaces?.includes(ScryptedInterface.Camera)}`);
655
- if (camera?.interfaces?.includes(ScryptedInterface.Camera)) {
656
- mediaObject = await camera.takePicture();
657
- this.console.log(`[Exit Alert] Got snapshot: ${!!mediaObject}`);
658
- }
719
+ spatialResult = await pendingDescription;
720
+ this.console.log(`[Exit Alert] Prefetch result: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
659
721
  } catch (e) {
660
- this.console.warn('[Exit Alert] Failed to get snapshot:', e);
722
+ this.console.warn(`[Exit Alert] Prefetch failed, generating fallback: ${e}`);
723
+ spatialResult = await this.spatialReasoning.generateExitDescription(current, sighting.cameraId);
661
724
  }
725
+ this.pendingDescriptions.delete(tracked.globalId);
726
+ } else {
727
+ // Fallback: generate description now (slower path)
728
+ this.console.log(`[Exit Alert] No prefetch available, generating now`);
729
+ const mediaObject = this.snapshotCache.get(tracked.globalId);
730
+ spatialResult = await this.spatialReasoning.generateExitDescription(current, sighting.cameraId, mediaObject);
731
+ this.console.log(`[Exit Alert] Got description: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`);
662
732
  }
663
733
 
664
- // Generate rich exit description using topology context (now async with LLM support)
665
- this.console.log(`[Exit Alert] Calling generateExitDescription with mediaObject=${!!mediaObject}`);
666
- const spatialResult = await this.spatialReasoning.generateExitDescription(
667
- current,
668
- sighting.cameraId,
669
- mediaObject
670
- );
671
-
672
- this.console.log(
673
- `[Exit Alert] Object ${tracked.globalId.slice(0, 8)} exited: "${spatialResult.description.substring(0, 60)}...", usedLlm=${spatialResult.usedLlm}`
674
- );
675
-
676
734
  await this.alertManager.checkAndAlert('property_exit', current, {
677
735
  cameraId: sighting.cameraId,
678
736
  cameraName: sighting.cameraName,
@@ -681,6 +739,10 @@ export class TrackingEngine {
681
739
  involvedLandmarks: spatialResult.involvedLandmarks?.map(l => l.name),
682
740
  usedLlm: spatialResult.usedLlm,
683
741
  });
742
+
743
+ // Clean up cached snapshot and pending descriptions after exit alert
744
+ this.snapshotCache.delete(tracked.globalId);
745
+ this.pendingDescriptions.delete(tracked.globalId);
684
746
  }
685
747
  this.pendingTimers.delete(tracked.globalId);
686
748
  }, this.config.correlationWindow);
@@ -703,6 +765,10 @@ export class TrackingEngine {
703
765
  `(not seen for ${Math.round(timeSinceSeen / 1000)}s)`
704
766
  );
705
767
 
768
+ // Clean up cached snapshot and pending descriptions
769
+ this.snapshotCache.delete(tracked.globalId);
770
+ this.pendingDescriptions.delete(tracked.globalId);
771
+
706
772
  this.alertManager.checkAndAlert('lost_tracking', tracked, {
707
773
  objectClass: tracked.className,
708
774
  objectLabel: tracked.label,