@blueharford/scrypted-spatial-awareness 0.6.15 → 0.6.17

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.15",
3
+ "version": "0.6.17",
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",
@@ -379,18 +379,33 @@ export class TopologyDiscoveryEngine {
379
379
  try {
380
380
  this.console.log(`[Discovery] Trying ${formatType} image format for ${cameraName}...`);
381
381
 
382
+ // Build prompt with camera context (height)
383
+ const cameraNode = this.topology ? findCamera(this.topology, cameraId) : null;
384
+ const mountHeight = cameraNode?.context?.mountHeight || 8;
385
+ const cameraRange = (cameraNode?.fov as any)?.range || 80;
386
+
387
+ // Add camera-specific context to the prompt
388
+ const contextPrefix = `CAMERA INFORMATION:
389
+ - Camera Name: ${cameraName}
390
+ - Mount Height: ${mountHeight} feet above ground
391
+ - Approximate viewing range: ${cameraRange} feet
392
+
393
+ Use the mount height to help estimate distances - objects at ground level will appear at different angles depending on distance from a camera mounted at ${mountHeight} feet.
394
+
395
+ `;
396
+
382
397
  // Build multimodal message with provider-specific image format
383
398
  const result = await llm.getChatCompletion({
384
399
  messages: [
385
400
  {
386
401
  role: 'user',
387
402
  content: [
388
- { type: 'text', text: SCENE_ANALYSIS_PROMPT },
403
+ { type: 'text', text: contextPrefix + SCENE_ANALYSIS_PROMPT },
389
404
  buildImageContent(imageData, formatType),
390
405
  ],
391
406
  },
392
407
  ],
393
- max_tokens: 1500,
408
+ max_tokens: 4000, // Increased for detailed scene analysis
394
409
  temperature: 0.3,
395
410
  });
396
411
 
@@ -403,7 +418,8 @@ export class TopologyDiscoveryEngine {
403
418
  jsonStr = jsonStr.replace(/```json?\n?/g, '').replace(/```$/g, '').trim();
404
419
  }
405
420
 
406
- const parsed = JSON.parse(jsonStr);
421
+ // Try to recover truncated JSON
422
+ const parsed = this.parseJsonWithRecovery(jsonStr, cameraName);
407
423
 
408
424
  // Map parsed data to our types
409
425
  if (Array.isArray(parsed.landmarks)) {
@@ -544,6 +560,99 @@ export class TopologyDiscoveryEngine {
544
560
  return 'medium'; // Default to medium if not specified
545
561
  }
546
562
 
563
+ /** Try to parse JSON with recovery for truncated responses */
564
+ private parseJsonWithRecovery(jsonStr: string, context: string): any {
565
+ // First, try direct parse
566
+ try {
567
+ return JSON.parse(jsonStr);
568
+ } catch (e) {
569
+ // Log the raw response for debugging (first 500 chars)
570
+ this.console.log(`[Discovery] Raw LLM response for ${context} (first 500 chars): ${jsonStr.substring(0, 500)}...`);
571
+ }
572
+
573
+ // Try to recover truncated JSON by finding complete sections
574
+ try {
575
+ // Find where valid JSON might end (look for last complete object/array)
576
+ let recoveredJson = jsonStr;
577
+
578
+ // Try to close unclosed strings
579
+ const lastQuote = recoveredJson.lastIndexOf('"');
580
+ const lastColon = recoveredJson.lastIndexOf(':');
581
+ if (lastQuote > lastColon) {
582
+ // We might be in the middle of a string value
583
+ const beforeQuote = recoveredJson.substring(0, lastQuote);
584
+ const afterLastCompleteEntry = beforeQuote.lastIndexOf('},');
585
+ if (afterLastCompleteEntry > 0) {
586
+ recoveredJson = beforeQuote.substring(0, afterLastCompleteEntry + 1);
587
+ }
588
+ }
589
+
590
+ // Close any unclosed arrays/objects
591
+ let openBraces = (recoveredJson.match(/{/g) || []).length;
592
+ let closeBraces = (recoveredJson.match(/}/g) || []).length;
593
+ let openBrackets = (recoveredJson.match(/\[/g) || []).length;
594
+ let closeBrackets = (recoveredJson.match(/\]/g) || []).length;
595
+
596
+ // Add missing closing brackets/braces
597
+ while (closeBrackets < openBrackets) {
598
+ recoveredJson += ']';
599
+ closeBrackets++;
600
+ }
601
+ while (closeBraces < openBraces) {
602
+ recoveredJson += '}';
603
+ closeBraces++;
604
+ }
605
+
606
+ const recovered = JSON.parse(recoveredJson);
607
+ this.console.log(`[Discovery] Recovered truncated JSON for ${context}`);
608
+ return recovered;
609
+ } catch (recoveryError) {
610
+ // Last resort: try to extract just landmarks array
611
+ try {
612
+ const landmarksMatch = jsonStr.match(/"landmarks"\s*:\s*\[([\s\S]*?)(?:\]|$)/);
613
+ const zonesMatch = jsonStr.match(/"zones"\s*:\s*\[([\s\S]*?)(?:\]|$)/);
614
+
615
+ const result: any = { landmarks: [], zones: [], edges: {}, orientation: 'unknown' };
616
+
617
+ if (landmarksMatch) {
618
+ // Try to parse individual landmark objects
619
+ const landmarksStr = landmarksMatch[1];
620
+ const landmarkObjects = landmarksStr.match(/\{[^{}]*\}/g) || [];
621
+ result.landmarks = landmarkObjects.map((obj: string) => {
622
+ try {
623
+ return JSON.parse(obj);
624
+ } catch {
625
+ return null;
626
+ }
627
+ }).filter(Boolean);
628
+ this.console.log(`[Discovery] Extracted ${result.landmarks.length} landmarks from partial response for ${context}`);
629
+ }
630
+
631
+ if (zonesMatch) {
632
+ const zonesStr = zonesMatch[1];
633
+ const zoneObjects = zonesStr.match(/\{[^{}]*\}/g) || [];
634
+ result.zones = zoneObjects.map((obj: string) => {
635
+ try {
636
+ return JSON.parse(obj);
637
+ } catch {
638
+ return null;
639
+ }
640
+ }).filter(Boolean);
641
+ this.console.log(`[Discovery] Extracted ${result.zones.length} zones from partial response for ${context}`);
642
+ }
643
+
644
+ if (result.landmarks.length > 0 || result.zones.length > 0) {
645
+ return result;
646
+ }
647
+ } catch (extractError) {
648
+ // Give up
649
+ }
650
+
651
+ this.console.warn(`[Discovery] Could not recover JSON for ${context}`);
652
+ throw new Error(`Failed to parse LLM response: truncated or malformed JSON`);
653
+ }
654
+ }
655
+
547
656
  /** Resolve a camera reference (name or deviceId) to its deviceId */
548
657
  private resolveCameraRef(ref: string): string | null {
549
658
  if (!this.topology?.cameras || !ref) return null;
@@ -1632,8 +1632,12 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1632
1632
  const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
1633
1633
  // Convert stored pixel range to feet for display
1634
1634
  const rangeInFeet = Math.round(pixelsToFeet(fov.range || 80));
1635
+ // Get mount height from context or default to 8
1636
+ const mountHeight = camera.context?.mountHeight || 8;
1635
1637
  panel.innerHTML = '<h3>Camera Properties</h3>' +
1636
1638
  '<div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div>' +
1639
+ '<div class="form-group"><label>Mount Height (feet)</label><input type="number" value="' + mountHeight + '" min="1" max="40" step="0.5" onchange="updateCameraMountHeight(\\'' + camera.deviceId + '\\', this.value)"></div>' +
1640
+ '<div style="font-size: 11px; color: #666; margin-top: -10px; margin-bottom: 10px;">Height affects distance estimation in discovery</div>' +
1637
1641
  '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div>' +
1638
1642
  '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div>' +
1639
1643
  '<h4 style="margin-top: 15px; margin-bottom: 10px; color: #888;">Field of View</h4>' +
@@ -1652,6 +1656,12 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1652
1656
  function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
1653
1657
  function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
1654
1658
  function updateCameraExit(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isExitPoint = value; }
1659
+ function updateCameraMountHeight(id, value) {
1660
+ const camera = topology.cameras.find(c => c.deviceId === id);
1661
+ if (!camera) return;
1662
+ if (!camera.context) camera.context = {};
1663
+ camera.context.mountHeight = parseFloat(value) || 8;
1664
+ }
1655
1665
  function updateCameraFov(id, field, value) {
1656
1666
  const camera = topology.cameras.find(c => c.deviceId === id);
1657
1667
  if (!camera) return;