@camera.ui/server 2.0.11 → 2.0.12

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.
Files changed (292) hide show
  1. package/dist/interface/assets/{404-COkoHYdK.js → 404-Dfyzm8bF.js} +1 -1
  2. package/dist/interface/assets/{About-qJICwbGH.js → About-B4Y3WP7T.js} +1 -1
  3. package/dist/interface/assets/{Adminpanel-D58GDcYK.js → Adminpanel-DSk1uh2x.js} +1 -1
  4. package/dist/interface/assets/Automation-CTSfLcmF.js +2 -0
  5. package/dist/interface/assets/{AutomationImport-jG2DzCAw.js → AutomationImport-B1P0T0Pe.js} +2 -2
  6. package/dist/interface/assets/AutomationStore-ZaaXPPbd.js +2 -0
  7. package/dist/interface/assets/AutomationStoreDetail-B2vc7mTZ.js +2 -0
  8. package/dist/interface/assets/Automations-Bvw-jTKi.js +2 -0
  9. package/dist/interface/assets/Camera-BxmJXk11.js +2 -0
  10. package/dist/interface/assets/{CameraConsole-BjtK8-Jv.js → CameraConsole-CVqgED5u.js} +1 -1
  11. package/dist/interface/assets/Cameras-DT62fcZK.js +652 -0
  12. package/dist/interface/assets/Camview-CqSckgLr.js +1 -0
  13. package/dist/interface/assets/Config-p9t9zgly.js +2 -0
  14. package/dist/interface/assets/{ConfigActionCameraControl-DjD6pSYf.js → ConfigActionCameraControl-BLROOU0q.js} +1 -1
  15. package/dist/interface/assets/{ConfigActionDelay-DGZlBe84.js → ConfigActionDelay-q0pOTOzh.js} +1 -1
  16. package/dist/interface/assets/{ConfigActionHttp-h5zpYYny.js → ConfigActionHttp-DiMXKRGz.js} +1 -1
  17. package/dist/interface/assets/ConfigActionImageInput-u5xX9t3-.js +1 -0
  18. package/dist/interface/assets/{ConfigActionMqtt-BnNmQUNe.js → ConfigActionMqtt-BkZl3uYq.js} +1 -1
  19. package/dist/interface/assets/ConfigActionNotification-DIkFNlKe.js +1 -0
  20. package/dist/interface/assets/{ConfigActionOutput-h2IebNMX.js → ConfigActionOutput-CJF0F4cT.js} +1 -1
  21. package/dist/interface/assets/{ConfigActionPlugin-B6pIqGWe.js → ConfigActionPlugin-rQeJ4umG.js} +1 -1
  22. package/dist/interface/assets/ConfigActionSensor-BWHpkoMs.js +1 -0
  23. package/dist/interface/assets/{ConfigActionSnapshot-Cs2TG5fZ.js → ConfigActionSnapshot-BlvQZl1R.js} +1 -1
  24. package/dist/interface/assets/{ConfigActionVariable-BPK-FHZ-.js → ConfigActionVariable-BMV-BDAk.js} +1 -1
  25. package/dist/interface/assets/{ConfigConditionIfElse-BNfUIXNh.js → ConfigConditionIfElse-C5geCT1S.js} +1 -1
  26. package/dist/interface/assets/ConfigConditionSensorState-8bIGEhQd.js +1 -0
  27. package/dist/interface/assets/{ConfigConditionSwitch-C7_-2l4Z.js → ConfigConditionSwitch-PwrCSnTQ.js} +1 -1
  28. package/dist/interface/assets/{ConfigConditionTime-DCTdB9RZ.js → ConfigConditionTime-DUt_dsr9.js} +1 -1
  29. package/dist/interface/assets/{ConfigSensorValueInput-Dt09C19b.js → ConfigSensorValueInput-CgZWxOMu.js} +1 -1
  30. package/dist/interface/assets/{ConfigTriggerDetection-CVTb6TEu.js → ConfigTriggerDetection-D4UitpXI.js} +1 -1
  31. package/dist/interface/assets/{ConfigTriggerGeofence-Cb62v6rf.js → ConfigTriggerGeofence-Ci-gkmjS.js} +1 -1
  32. package/dist/interface/assets/{ConfigTriggerManual-agJhIaCy.js → ConfigTriggerManual-_PAVLJxz.js} +1 -1
  33. package/dist/interface/assets/{ConfigTriggerMqtt-DWZ79tPv.js → ConfigTriggerMqtt-BtfXQ_Bh.js} +1 -1
  34. package/dist/interface/assets/{ConfigTriggerSchedule-C38lrm2R.js → ConfigTriggerSchedule-CHyW4YQ1.js} +1 -1
  35. package/dist/interface/assets/{ConfigTriggerSensor-BKXoFFFT.js → ConfigTriggerSensor-CZMplCYs.js} +1 -1
  36. package/dist/interface/assets/{ConfigTriggerSystem-DqoMfULb.js → ConfigTriggerSystem-OOffe8rK.js} +1 -1
  37. package/dist/interface/assets/{ConfigTriggerWebhook-DunygdEo.js → ConfigTriggerWebhook-Baul6M1Y.js} +1 -1
  38. package/dist/interface/assets/Console-MXiL0z_X.js +1 -0
  39. package/dist/interface/assets/{CuiActionButton-iaZkXylf.js → CuiActionButton-WFz5I80P.js} +1 -1
  40. package/dist/interface/assets/{CuiAvatar--d4yBFev.js → CuiAvatar-BUwQnIaI.js} +1 -1
  41. package/dist/interface/assets/{CuiBBoxPlayground-BuDfU2lQ.js → CuiBBoxPlayground-J7_8dCvo.js} +1 -1
  42. package/dist/interface/assets/CuiBottomSheet-BA-u9UtN.js +1 -0
  43. package/dist/interface/assets/{CuiBottomSheet-o1iNIub8.js → CuiBottomSheet-CzG0n4Oa.js} +1 -1
  44. package/dist/interface/assets/CuiCameraCard-GH-DR5_s.js +1 -0
  45. package/dist/interface/assets/{CuiCameraPipCard-NA4CctaN.js → CuiCameraPipCard-CL_wj05b.js} +1 -1
  46. package/dist/interface/assets/CuiCameraRecordings-BjErk9Gl.js +1 -0
  47. package/dist/interface/assets/CuiCameraShares-qrJhZMEt.js +1 -0
  48. package/dist/interface/assets/{CuiCameraSnapshot-Dp6cLkMa.js → CuiCameraSnapshot-Dn1QSGrr.js} +1 -1
  49. package/dist/interface/assets/CuiCameraTable-or8VpE34.js +2 -0
  50. package/dist/interface/assets/{CuiDataTable-BDb1Qvzm.js → CuiDataTable-CjXiUFiJ.js} +1 -1
  51. package/dist/interface/assets/CuiFloatingButton-BjpU1EHv.js +1 -0
  52. package/dist/interface/assets/CuiImage-BM4g6edi.css +1 -0
  53. package/dist/interface/assets/CuiImage-Bq3bxOOL.js +1 -0
  54. package/dist/interface/assets/CuiMenu-DtnIPhcr.js +1 -0
  55. package/dist/interface/assets/CuiPluginCard-DKNWUFpR.js +2 -0
  56. package/dist/interface/assets/{CuiPluginCategoryChip-R5WUBAUw.js → CuiPluginCategoryChip-cmfKx-dU.js} +1 -1
  57. package/dist/interface/assets/CuiPluginOAuthButton-C6FQiVkt.js +2 -0
  58. package/dist/interface/assets/CuiPluginTrustBadge-PVZMP1rI.js +1 -0
  59. package/dist/interface/assets/{CuiQRCode-CsxVDVaU.js → CuiQRCode-uQygC1dP.js} +1 -1
  60. package/dist/interface/assets/CuiRecordingsGrid-Cuwd4L67.js +1 -0
  61. package/dist/interface/assets/CuiSchema-CH0GXMWV.js +1 -0
  62. package/dist/interface/assets/{CuiTopNavbarItem-CIO_h1T0.js → CuiTopNavbarItem-nGcC2em4.js} +1 -1
  63. package/dist/interface/assets/{CuiTopbarSlot-C6t4NmW-.js → CuiTopbarSlot-B6UMqYZl.js} +1 -1
  64. package/dist/interface/assets/Faces-cpO5vJbp.js +1 -0
  65. package/dist/interface/assets/{FirstSteps-CP26tE1m.js → FirstSteps-BHTcbs9O.js} +1 -1
  66. package/dist/interface/assets/{Instances-Bdy0n3fB.js → Instances-BXiVG2Dm.js} +1 -1
  67. package/dist/interface/assets/Logs-hMg2QOGj.js +6 -0
  68. package/dist/interface/assets/{Menu-DX0KhBBs.js → Menu-BiPBCi8Z.js} +1 -1
  69. package/dist/interface/assets/{OAuthDeviceFlow-Cci9ex4G.js → OAuthDeviceFlow-Bia9UA3I.js} +1 -1
  70. package/dist/interface/assets/Plugin-DfRaHAsi.js +1 -0
  71. package/dist/interface/assets/{PluginConsole-Fffl7Yh_.js → PluginConsole-CRw4zTRM.js} +1 -1
  72. package/dist/interface/assets/PluginDetail-BYqji8yN.css +1 -0
  73. package/dist/interface/assets/PluginDetail-BqRoLZc7.js +2 -0
  74. package/dist/interface/assets/{PluginSchema-BNEfEDJU.js → PluginSchema-TtBJPR_R.js} +1 -1
  75. package/dist/interface/assets/PluginSearch-B_NYpJi3.js +2 -0
  76. package/dist/interface/assets/{PluginSearch-NuKgD1r3.css → PluginSearch-Bbp8D7BJ.css} +1 -1
  77. package/dist/interface/assets/PluginSearch-DnJgg6-6.js +1 -0
  78. package/dist/interface/assets/Plugins-DVFEGtj3.js +1 -0
  79. package/dist/interface/assets/Recordings-dwm6lAoJ.js +1 -0
  80. package/dist/interface/assets/{Settings-TkiQG2I9.js → Settings-BpBmrGpS.js} +1 -1
  81. package/dist/interface/assets/SettingsAccount-8Kh1uQqn.js +1 -0
  82. package/dist/interface/assets/SettingsAccount-CjgYe5mF.js +1 -0
  83. package/dist/interface/assets/SettingsAppearance-3i3Ix7j-.js +1 -0
  84. package/dist/interface/assets/{SettingsAppearance-CWWSQkIY.js → SettingsAppearance-EoBxclgt.js} +1 -1
  85. package/dist/interface/assets/SettingsBackup-Bpsbg6Pm.js +1 -0
  86. package/dist/interface/assets/{SettingsMqtt-CeurNe8m.js → SettingsMqtt-ZQolc9lR.js} +1 -1
  87. package/dist/interface/assets/SettingsNotifications-BOoMt1Er.js +2 -0
  88. package/dist/interface/assets/SettingsRecordings-BQhxLCgM.js +1 -0
  89. package/dist/interface/assets/SettingsRemote-Cvcmpdgb.js +1 -0
  90. package/dist/interface/assets/SettingsSystem-B-yhq40t.js +2 -0
  91. package/dist/interface/assets/SettingsUsers-CezygBk8.js +1 -0
  92. package/dist/interface/assets/Terminal-CDQJGwb_.js +4 -0
  93. package/dist/interface/assets/{VariableInput-BuXTKXXC.js → VariableInput-CnmqCHf7.js} +1 -1
  94. package/dist/interface/assets/{VariableSuggestions-BIPtw8GW.js → VariableSuggestions-D6IlwBqV.js} +1 -1
  95. package/dist/interface/assets/VersionsHandler-BHR1k9zz.js +7 -0
  96. package/dist/interface/assets/Workers-Bv-bWBC-.js +2 -0
  97. package/dist/interface/assets/{api-DudQz5TK.js → api-BhOwzvxK.js} +1 -1
  98. package/dist/interface/assets/api-Cea16mc8.js +2 -0
  99. package/dist/interface/assets/api-GMfqGYqQ.css +2 -0
  100. package/dist/interface/assets/{auth-BKlPnLfY.js → auth-CNPkdTXX.js} +1 -1
  101. package/dist/interface/assets/{automations-eQnuQOEs.js → automations-CHengR-B.js} +1 -1
  102. package/dist/interface/assets/{automations-Dtp3-KSy.js → automations-DhLADBhd.js} +1 -1
  103. package/dist/interface/assets/{cameras-BySMxwdP.js → cameras-DyF2T0be.js} +1 -1
  104. package/dist/interface/assets/{cameras.schema-DVnsQl9j.js → cameras.schema-DBzgU4m6.js} +1 -1
  105. package/dist/interface/assets/cloudHandoff-LxdvSi1a.js +1 -0
  106. package/dist/interface/assets/{config-CDCKrp2V.js → config-c7o4sJrQ.js} +1 -1
  107. package/dist/interface/assets/{circular-connection-Fq-Vqe3X.js → delete-outline-CbGU9XWz.js} +1 -1
  108. package/dist/interface/assets/{deviceId-O-kfs3nO.js → deviceId-BYu9Q8Ay.js} +1 -1
  109. package/dist/interface/assets/{hasPermission-DADXQ_dj.js → hasPermission-CgyWyOhQ.js} +1 -1
  110. package/dist/interface/assets/{instances-DVk8sRuN.js → instances-CRYtaTQS.js} +1 -1
  111. package/dist/interface/assets/main-CgXkx5UO.js +3 -0
  112. package/dist/interface/assets/{notifications-Dnh_ylVF.js → notifications-sejZCb0L.js} +1 -1
  113. package/dist/interface/assets/{plugins-CHRZiPTq.js → plugins-CCLFcD4c.js} +1 -1
  114. package/dist/interface/assets/round-restart-alt-PDSZur-0.js +1 -0
  115. package/dist/interface/assets/{router-M08TaIji.css → router-C7b6u3Zu.css} +1 -1
  116. package/dist/interface/assets/router-DkMUvvnP.js +2 -0
  117. package/dist/interface/assets/{routerState-CencmCOo.js → routerState-BR88YV1H.js} +1 -1
  118. package/dist/interface/assets/{server-zkNDKqjz.js → server-C7SETTy9.js} +1 -1
  119. package/dist/interface/assets/{shares-DooKpXt5.js → shares-Cy1SLL98.js} +1 -1
  120. package/dist/interface/assets/{theme-Dlx1uc6I.js → theme-DqbhRu7y.js} +1 -1
  121. package/dist/interface/assets/{types-DwLgLlNL.js → types-v8L66UlA.js} +1 -1
  122. package/dist/interface/assets/{ui-BajsbwJJ.js → ui-g5rCwxUE.js} +1 -1
  123. package/dist/interface/assets/{useAppVersion-Vx2rWjdR.js → useAppVersion-WfdoG9Ji.js} +1 -1
  124. package/dist/interface/assets/{useBackupRestore-CtDDTNYM.js → useBackupRestore-ootsFhdF.js} +1 -1
  125. package/dist/interface/assets/{useBiometricCredentials-DnEuKj2Y.js → useBiometricCredentials-Ca75xEru.js} +1 -1
  126. package/dist/interface/assets/{useCameraOptions-CurEfrEs.js → useCameraOptions-BpL2zbfV.js} +1 -1
  127. package/dist/interface/assets/{useCuiCameraDrawer-eH0m75Mk.js → useCuiCameraDrawer-BHUHw6W6.js} +1 -1
  128. package/dist/interface/assets/useCuiDialog-yVh5UWKF.js +2 -0
  129. package/dist/interface/assets/{useFileDownload-CaB5Myfa.js → useFileDownload-BcaNWndS.js} +1 -1
  130. package/dist/interface/assets/useLogsSocket-BRSsUNqv.js +5 -0
  131. package/dist/interface/assets/{useServerRestart-COGxcO4B.js → useServerRestart-PZhND5H_.js} +1 -1
  132. package/dist/interface/assets/{useSharedComposables-DC7dAxBt.js → useSharedComposables-USqd05AG.js} +1 -1
  133. package/dist/interface/assets/{useStreamStatus-Cr_bBYAe.js → useStreamStatus-nCx5ux1-.js} +1 -1
  134. package/dist/interface/assets/{useUpdateChannel-D_SN9IPN.js → useUpdateChannel-3eHk3KXZ.js} +1 -1
  135. package/dist/interface/assets/{useUserOptions-CtgFdv3y.js → useUserOptions-DVC4k8Zs.js} +1 -1
  136. package/dist/interface/assets/{users.schema-vs5FAhpT.js → users.schema-B4fEuoKZ.js} +1 -1
  137. package/dist/interface/assets/{vendor-cameraui-D5fd9Ars.js → vendor-cameraui-QuTJKoXE.js} +84 -23
  138. package/dist/interface/assets/vendor-core-CCcu_nmz.js +1 -0
  139. package/dist/interface/assets/{vendor-primevue-Ba4LruKV.js → vendor-primevue-BdB7McKg.js} +84 -145
  140. package/dist/interface/assets/view-grid-BWJS0bBD.js +1 -0
  141. package/dist/interface/index.html +47 -47
  142. package/dist/interface/sw.js +1 -1
  143. package/dist/server/package.json +6 -6
  144. package/dist/server/requirements.txt +1 -1
  145. package/dist/server/src/_camera_ui_tools/camera_ui_sdk/__init__.py +2 -0
  146. package/dist/server/src/_camera_ui_tools/camera_ui_sdk/camera/detection.py +3 -0
  147. package/dist/server/src/_camera_ui_tools/camera_ui_sdk/sensor/__init__.py +2 -0
  148. package/dist/server/src/_camera_ui_tools/camera_ui_sdk/sensor/ptz.py +45 -2
  149. package/dist/server/src/api/database/backfill.d.ts +5 -0
  150. package/dist/server/src/api/database/backfill.js +89 -0
  151. package/dist/server/src/api/database/backfill.js.map +1 -0
  152. package/dist/server/src/api/database/index.d.ts +1 -0
  153. package/dist/server/src/api/database/index.js +29 -43
  154. package/dist/server/src/api/database/index.js.map +1 -1
  155. package/dist/server/src/api/database/record-schemas.d.ts +231 -0
  156. package/dist/server/src/api/database/record-schemas.js +194 -0
  157. package/dist/server/src/api/database/record-schemas.js.map +1 -0
  158. package/dist/server/src/api/schemas/cameras.schema.d.ts +265 -1
  159. package/dist/server/src/api/schemas/cameras.schema.js +10 -0
  160. package/dist/server/src/api/schemas/cameras.schema.js.map +1 -1
  161. package/dist/server/src/api/schemas/remote.schema.d.ts +1 -1
  162. package/dist/server/src/api/services/cameras.service.d.ts +1 -0
  163. package/dist/server/src/api/services/cameras.service.js +18 -1
  164. package/dist/server/src/api/services/cameras.service.js.map +1 -1
  165. package/dist/server/src/api/services/plugins.service.js +15 -4
  166. package/dist/server/src/api/services/plugins.service.js.map +1 -1
  167. package/dist/server/src/api/websocket/nsp/main.js +3 -0
  168. package/dist/server/src/api/websocket/nsp/main.js.map +1 -1
  169. package/dist/server/src/camera/controller.js +1 -12
  170. package/dist/server/src/camera/controller.js.map +1 -1
  171. package/dist/server/src/camera/decoder/audio-loop.d.ts +25 -0
  172. package/dist/server/src/camera/decoder/audio-loop.js +163 -0
  173. package/dist/server/src/camera/decoder/audio-loop.js.map +1 -0
  174. package/dist/server/src/camera/decoder/cascade-manager.js +1 -4
  175. package/dist/server/src/camera/decoder/cascade-manager.js.map +1 -1
  176. package/dist/server/src/camera/decoder/detection-coordinator.d.ts +36 -97
  177. package/dist/server/src/camera/decoder/detection-coordinator.js +479 -1387
  178. package/dist/server/src/camera/decoder/detection-coordinator.js.map +1 -1
  179. package/dist/server/src/camera/decoder/detection-pipeline.d.ts +4 -0
  180. package/dist/server/src/camera/decoder/detection-pipeline.js +14 -24
  181. package/dist/server/src/camera/decoder/detection-pipeline.js.map +1 -1
  182. package/dist/server/src/camera/decoder/dwell-manager.js +1 -1
  183. package/dist/server/src/camera/decoder/dwell-manager.js.map +1 -1
  184. package/dist/server/src/camera/decoder/event-manager.js +15 -28
  185. package/dist/server/src/camera/decoder/event-manager.js.map +1 -1
  186. package/dist/server/src/camera/decoder/event-thumbnailer.d.ts +38 -0
  187. package/dist/server/src/camera/decoder/event-thumbnailer.js +192 -0
  188. package/dist/server/src/camera/decoder/event-thumbnailer.js.map +1 -0
  189. package/dist/server/src/camera/decoder/plugin-registry.d.ts +31 -0
  190. package/dist/server/src/camera/decoder/plugin-registry.js +114 -0
  191. package/dist/server/src/camera/decoder/plugin-registry.js.map +1 -0
  192. package/dist/server/src/camera/decoder/{ptz-autotracker.d.ts → ptz/autotracker.d.ts} +19 -0
  193. package/dist/server/src/camera/decoder/ptz/autotracker.js +537 -0
  194. package/dist/server/src/camera/decoder/ptz/autotracker.js.map +1 -0
  195. package/dist/server/src/camera/decoder/reconnect-backoff.d.ts +6 -0
  196. package/dist/server/src/camera/decoder/reconnect-backoff.js +17 -0
  197. package/dist/server/src/camera/decoder/reconnect-backoff.js.map +1 -0
  198. package/dist/server/src/camera/decoder/secondary-stage.d.ts +37 -0
  199. package/dist/server/src/camera/decoder/secondary-stage.js +445 -0
  200. package/dist/server/src/camera/decoder/secondary-stage.js.map +1 -0
  201. package/dist/server/src/camera/decoder/{audio-source.js → sources/audio-source.js} +2 -2
  202. package/dist/server/src/camera/decoder/sources/audio-source.js.map +1 -0
  203. package/dist/server/src/camera/decoder/{frame-source.js → sources/frame-source.js} +8 -11
  204. package/dist/server/src/camera/decoder/sources/frame-source.js.map +1 -0
  205. package/dist/server/src/camera/decoder/{snapshot-source.d.ts → sources/snapshot-source.d.ts} +2 -5
  206. package/dist/server/src/camera/decoder/{snapshot-source.js → sources/snapshot-source.js} +11 -15
  207. package/dist/server/src/camera/decoder/sources/snapshot-source.js.map +1 -0
  208. package/dist/server/src/camera/decoder/stationary-suppressor.d.ts +21 -0
  209. package/dist/server/src/camera/decoder/stationary-suppressor.js +141 -0
  210. package/dist/server/src/camera/decoder/stationary-suppressor.js.map +1 -0
  211. package/dist/server/src/camera/decoder/types.d.ts +6 -1
  212. package/dist/server/src/camera/decoder/types.js +1 -0
  213. package/dist/server/src/camera/decoder/types.js.map +1 -1
  214. package/dist/server/src/camera/decoder/worker.js +4 -7
  215. package/dist/server/src/camera/decoder/worker.js.map +1 -1
  216. package/dist/server/src/go2rtc/api/routes/discover.js +2 -1
  217. package/dist/server/src/go2rtc/api/routes/discover.js.map +1 -1
  218. package/dist/server/src/main.js +26 -3
  219. package/dist/server/src/main.js.map +1 -1
  220. package/dist/server/src/manager/downloadManager.js +0 -2
  221. package/dist/server/src/manager/downloadManager.js.map +1 -1
  222. package/dist/server/src/utils/npm/index.d.ts +10 -1
  223. package/dist/server/src/utils/npm/index.js +11 -1
  224. package/dist/server/src/utils/npm/index.js.map +1 -1
  225. package/dist/server/src/workers/capabilities/plugin-host.js +4 -1
  226. package/dist/server/src/workers/capabilities/plugin-host.js.map +1 -1
  227. package/package.json +6 -6
  228. package/dist/interface/assets/Automation-Dp_xaEqJ.js +0 -2
  229. package/dist/interface/assets/AutomationStore-4JFCinMU.js +0 -2
  230. package/dist/interface/assets/AutomationStoreDetail-yBtpTcZc.js +0 -2
  231. package/dist/interface/assets/Automations-BGsRPCnO.js +0 -2
  232. package/dist/interface/assets/Camera-C1_jdI_c.js +0 -2
  233. package/dist/interface/assets/Cameras-BmjWXAZ8.js +0 -652
  234. package/dist/interface/assets/Camview-wGaYaDmi.js +0 -1
  235. package/dist/interface/assets/Config-CuF6eGTD.js +0 -2
  236. package/dist/interface/assets/ConfigActionImageInput-xab5Mae-.js +0 -1
  237. package/dist/interface/assets/ConfigActionNotification-DGwL1eKM.js +0 -1
  238. package/dist/interface/assets/ConfigActionSensor-DBf7_RrD.js +0 -1
  239. package/dist/interface/assets/ConfigConditionSensorState-D1IWa7jK.js +0 -1
  240. package/dist/interface/assets/Console-DL4o8TCN.js +0 -1
  241. package/dist/interface/assets/CuiBottomSheet-DluJK839.js +0 -1
  242. package/dist/interface/assets/CuiCameraCard-CNkJcJNk.js +0 -1
  243. package/dist/interface/assets/CuiCameraRecordings-Dg_xNATQ.js +0 -1
  244. package/dist/interface/assets/CuiCameraShares-BSdoTLlP.js +0 -1
  245. package/dist/interface/assets/CuiCameraTable-16e0lAA5.js +0 -2
  246. package/dist/interface/assets/CuiFloatingButton-B64PYWE2.js +0 -1
  247. package/dist/interface/assets/CuiImage-Bxf7z2N4.css +0 -1
  248. package/dist/interface/assets/CuiImage-D_DDxN1t.js +0 -1
  249. package/dist/interface/assets/CuiMenu-BmgnEZun.js +0 -1
  250. package/dist/interface/assets/CuiPluginCard-C0GXkGJ3.js +0 -2
  251. package/dist/interface/assets/CuiPluginOAuthButton-CY3c3HRs.js +0 -2
  252. package/dist/interface/assets/CuiPluginTrustBadge-QRsixP01.js +0 -1
  253. package/dist/interface/assets/CuiRecordingsGrid-DycYYi7W.js +0 -1
  254. package/dist/interface/assets/CuiSchema-D1-Z2sTy.js +0 -1
  255. package/dist/interface/assets/Faces-QSA5E6EG.js +0 -1
  256. package/dist/interface/assets/Logs-Bs0C7Yu1.js +0 -6
  257. package/dist/interface/assets/Plugin-CG02A29i.js +0 -1
  258. package/dist/interface/assets/PluginDetail-BsIkPUY8.css +0 -1
  259. package/dist/interface/assets/PluginDetail-CRCMotvN.js +0 -2
  260. package/dist/interface/assets/PluginSearch-CwNCQedL.js +0 -2
  261. package/dist/interface/assets/PluginSearch-GD7qtALl.js +0 -1
  262. package/dist/interface/assets/Plugins-BNiQus_y.js +0 -1
  263. package/dist/interface/assets/Recordings-dY0k4i3E.js +0 -1
  264. package/dist/interface/assets/SettingsAccount-Cln0bZPI.js +0 -1
  265. package/dist/interface/assets/SettingsAccount-DvrdANbI.js +0 -1
  266. package/dist/interface/assets/SettingsAppearance-CVMAyxFr.js +0 -1
  267. package/dist/interface/assets/SettingsBackup-CZEKi84z.js +0 -1
  268. package/dist/interface/assets/SettingsNotifications-BBYcFlOe.js +0 -2
  269. package/dist/interface/assets/SettingsRecordings-8JRnUwvv.js +0 -1
  270. package/dist/interface/assets/SettingsRemote-BXLy9grM.js +0 -1
  271. package/dist/interface/assets/SettingsSystem-BwVJ2-Ws.js +0 -2
  272. package/dist/interface/assets/SettingsUsers-CMsX8YO6.js +0 -1
  273. package/dist/interface/assets/Terminal-CNAaV5h5.js +0 -4
  274. package/dist/interface/assets/VersionsHandler-0Ol7oxfg.js +0 -7
  275. package/dist/interface/assets/Workers-BYGXvih8.js +0 -2
  276. package/dist/interface/assets/api-BEwr0GLC.css +0 -2
  277. package/dist/interface/assets/api-CeJqvqFj.js +0 -2
  278. package/dist/interface/assets/cloudHandoff-DOYq2IS2.js +0 -1
  279. package/dist/interface/assets/main-DHUUsPUM.js +0 -3
  280. package/dist/interface/assets/round-restart-alt-BRh54UpI.js +0 -1
  281. package/dist/interface/assets/router-B6AZz5fb.js +0 -2
  282. package/dist/interface/assets/useCuiDialog-CGak2kyV.js +0 -2
  283. package/dist/interface/assets/useLogsSocket-B47qvN41.js +0 -5
  284. package/dist/interface/assets/vendor-core-D9amXF-u.js +0 -1
  285. package/dist/interface/assets/view-grid-jcP3VugM.js +0 -1
  286. package/dist/server/src/camera/decoder/audio-source.js.map +0 -1
  287. package/dist/server/src/camera/decoder/frame-source.js.map +0 -1
  288. package/dist/server/src/camera/decoder/ptz-autotracker.js +0 -342
  289. package/dist/server/src/camera/decoder/ptz-autotracker.js.map +0 -1
  290. package/dist/server/src/camera/decoder/snapshot-source.js.map +0 -1
  291. /package/dist/server/src/camera/decoder/{audio-source.d.ts → sources/audio-source.d.ts} +0 -0
  292. /package/dist/server/src/camera/decoder/{frame-source.d.ts → sources/frame-source.d.ts} +0 -0
@@ -1,4 +1,3 @@
1
- /* eslint-disable @stylistic/max-len */
2
1
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
2
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
3
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -8,154 +7,56 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
7
  var __metadata = (this && this.__metadata) || function (k, v) {
9
8
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
9
  };
11
- var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
12
- if (value !== null && value !== void 0) {
13
- if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
14
- var dispose, inner;
15
- if (async) {
16
- if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
17
- dispose = value[Symbol.asyncDispose];
18
- }
19
- if (dispose === void 0) {
20
- if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
21
- dispose = value[Symbol.dispose];
22
- if (async) inner = dispose;
23
- }
24
- if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
25
- if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
26
- env.stack.push({ value: value, dispose: dispose, async: async });
27
- }
28
- else if (async) {
29
- env.stack.push({ async: true });
30
- }
31
- return value;
32
- };
33
- var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
34
- return function (env) {
35
- function fail(e) {
36
- env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
37
- env.hasError = true;
38
- }
39
- var r, s = 0;
40
- function next() {
41
- while (r = env.stack.pop()) {
42
- try {
43
- if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
44
- if (r.dispose) {
45
- var result = r.dispose.call(r.value);
46
- if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
47
- }
48
- else s |= 1;
49
- }
50
- catch (e) {
51
- fail(e);
52
- }
53
- }
54
- if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
55
- if (env.hasError) throw env.error;
56
- }
57
- return next();
58
- };
59
- })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
60
- var e = new Error(message);
61
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
62
- });
10
+ import { PromiseTimeout, sleep } from '@camera.ui/common/utils';
63
11
  import { isNoRespondersError, RPCClass, RPCMethod } from '@camera.ui/rpc';
64
12
  import { SensorType } from '@camera.ui/sdk';
65
13
  import { NamespaceManager } from '../../rpc/namespaces.js';
66
- import { DETECTION_SENSOR_TYPES, SENSOR_TYPE_CONFIG } from '../sensors/types.js';
14
+ import { DETECTION_SENSOR_TYPES } from '../sensors/types.js';
67
15
  import { normalizeZone } from '../utils/filter.js';
68
- import { AudioSource } from './audio-source.js';
16
+ import { AudioDetectionLoop } from './audio-loop.js';
69
17
  import { CascadeManager } from './cascade-manager.js';
70
18
  import { DetectionPipeline } from './detection-pipeline.js';
71
19
  import { DwellManager } from './dwell-manager.js';
72
20
  import { DetectionEventManager } from './event-manager.js';
21
+ import { EventThumbnailer } from './event-thumbnailer.js';
73
22
  import { FrameScaler } from './frame-scaler.js';
74
- import { FrameSource } from './frame-source.js';
75
- import { PtzAutotracker } from './ptz-autotracker.js';
76
- import { SnapshotSource } from './snapshot-source.js';
77
- import { MOTION_WIDTH_MAP } from './types.js';
78
- // Default cascade hold-open window in seconds
79
- export const DEFAULT_CASCADE_TIMEOUT = 10;
80
- // Tracks below this speed (normalized units/s) are considered stationary.
81
- // Typical values: parked bike ~0.0006, standing person ~0.003–0.03.
82
- export const STATIONARY_SPEED_THRESHOLD = 0.002;
23
+ import { isVideoInputSpec, PluginRegistry } from './plugin-registry.js';
24
+ import { PtzAutotracker } from './ptz/autotracker.js';
25
+ import { ReconnectBackoff } from './reconnect-backoff.js';
26
+ import { SecondaryStage } from './secondary-stage.js';
27
+ import { FrameSource } from './sources/frame-source.js';
28
+ import { StationarySuppressor } from './stationary-suppressor.js';
29
+ import { DETECT_TIMEOUT_MS, MOTION_WIDTH_MAP } from './types.js';
30
+ const DEFAULT_CASCADE_TIMEOUT = 10;
83
31
  const OBJECT_DWELL_SECONDS = 2;
84
- const DETECT_TIMEOUT_MS = 30_000;
85
- const EVENT_THUMB_MAX_WIDTH = 640;
86
- const DETECTION_THUMB_MAX_WIDTH = 640;
87
- const DETECTION_THUMB_MIN_CROP = 160;
88
- const DETECTION_THUMB_HQ_MAX_WIDTH = 640;
89
- const DETECTION_THUMB_HQ_MIN_CROP = 640;
90
- const DETECTION_THUMB_HQ_QUALITY = 80;
91
- const ATTRIBUTE_THUMB_MAX_WIDTH = 640;
92
- const ATTRIBUTE_THUMB_MIN_CROP = 128;
93
- const EVENT_THUMB_HQ_MAX_WIDTH = 960;
94
- const EVENT_THUMB_HQ_QUALITY = 75;
95
- const HQ_FRAME_MAX_AGE_MS = 500;
96
- async function withTimeout(promise, ms, label) {
97
- let timer;
98
- const timeout = new Promise((_resolve, reject) => {
99
- timer = setTimeout(() => reject(new Error(`${label} detection timed out after ${ms}ms`)), ms);
100
- });
101
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
102
- }
32
+ const SECONDARY_BBOX_TTL_MS = 2000;
103
33
  let DetectionCoordinator = class DetectionCoordinator {
104
34
  config;
105
35
  proxy;
106
36
  logger;
107
37
  frameSource;
108
38
  frameScaler;
109
- singlePlugins = new Map();
110
- multiPlugins = new Map();
111
- loopRunning = false;
112
- loopPromise;
113
- videoStopPromise;
114
- audioSource;
115
- audioLoopRunning = false;
116
- audioLoopPromise;
117
- audioStopPromise;
118
- audioReconnectCount = 0;
119
- reconnectCount = 0;
120
- maxReconnectCount = 10;
121
- normalReconnectDelay = 10000;
122
- maxReconnectDelay = 60000;
39
+ plugins = new PluginRegistry();
40
+ pipeline;
41
+ stationary;
42
+ secondaries;
43
+ thumbnailer;
44
+ audioLoop;
123
45
  ptzAutotracker;
124
46
  eventManager;
125
47
  cascade = new CascadeManager();
126
48
  dwell = new DwellManager();
127
- pipeline;
49
+ videoBackoff = new ReconnectBackoff();
50
+ loopRunning = false;
51
+ loopPromise;
52
+ videoStopPromise;
128
53
  adHocVideoLoop = false;
129
54
  processingExternalSecondary = false;
130
- hqSnapshotSource;
131
- hqUpgradeInflight = false;
132
- hqAspectWarned = false;
133
55
  cascadeUnsubscribe;
134
56
  dwellUnsubscribe;
135
- seenStationaryTracks = new Set();
136
57
  activeSensorTriggerTypes = new Map();
58
+ secondaryBboxSeen = new Map();
137
59
  currentDetectionState = {};
138
- get cascadeEnabled() {
139
- return this.config.detectionSettings.cascadeDetection !== false;
140
- }
141
- get cascadeTimeoutSeconds() {
142
- return this.config.detectionSettings.cascadeTimeout ?? DEFAULT_CASCADE_TIMEOUT;
143
- }
144
- get audioMinDecibels() {
145
- return this.config.detectionSettings.audio.minDecibels;
146
- }
147
- get motionResolution() {
148
- return this.config.detectionSettings.motion.resolution;
149
- }
150
- get targetFps() {
151
- return this.frameSource.fps;
152
- }
153
- get videoAspectRatio() {
154
- const [w, h] = this.config.interfaceSettings.aspectRatio.split(':').map(Number);
155
- return w / h;
156
- }
157
- nvrProxy;
158
- nvrProxyPromise;
159
60
  constructor(config, proxy, logger) {
160
61
  this.config = config;
161
62
  this.proxy = proxy;
@@ -178,22 +79,29 @@ let DetectionCoordinator = class DetectionCoordinator {
178
79
  this.frameSource = new FrameSource(frameSourceConfig, logger);
179
80
  this.frameScaler = new FrameScaler(null, logger);
180
81
  this.pipeline = new DetectionPipeline(config.zones, config.detectionSettings);
181
- const hqUrl = this.resolveHqSourceUrl(config.availableSources);
182
- if (hqUrl) {
183
- this.hqSnapshotSource = new SnapshotSource({ url: hqUrl }, logger);
184
- }
185
- this.syncHqSnapshotSource();
82
+ this.stationary = new StationarySuppressor(logger);
83
+ this.secondaries = new SecondaryStage(this.plugins, this.pipeline, this.frameScaler, this.proxy, logger, () => !this.loopRunning);
186
84
  if (config.lines.length > 0) {
187
85
  this.pipeline.updateLines(config.lines, this.videoAspectRatio);
188
86
  }
189
87
  this.eventManager = new DetectionEventManager(config.cameraId, this.proxy, this.logger);
190
88
  this.eventManager.onEventEnd(() => this.handleEventEnded());
89
+ this.thumbnailer = new EventThumbnailer({ frameSource: this.frameSource, frameScaler: this.frameScaler, eventManager: this.eventManager, logger }, config.availableSources);
90
+ this.thumbnailer.sync(this.config.frameWorkerSettings.hqSnapshots === true);
91
+ this.audioLoop = new AudioDetectionLoop({
92
+ cameraId: config.cameraId,
93
+ getPlugin: () => this.plugins.get(SensorType.Audio),
94
+ getStreamUrl: () => this.config.audioStreamUrl,
95
+ getMinDecibels: () => this.config.detectionSettings.audio.minDecibels,
96
+ onResult: (sensorId, result) => this.handleAudioLoopResult(sensorId, result),
97
+ }, logger);
191
98
  this.ptzAutotracker = new PtzAutotracker({
192
99
  logger: this.logger,
193
100
  proxy: this.proxy,
194
101
  cameraId: this.config.cameraId,
195
102
  settings: this.config.ptzAutotrack,
196
- onSuppressionActivated: () => this.clearMotionStateForSuppression(),
103
+ getFps: () => this.targetFps,
104
+ onSuppressionActivated: () => this.handleSuppressionActivated(),
197
105
  });
198
106
  this.cascadeUnsubscribe = this.cascade.onChange((event) => {
199
107
  if (event.type === 'activated') {
@@ -209,8 +117,7 @@ let DetectionCoordinator = class DetectionCoordinator {
209
117
  this.dwellUnsubscribe = this.dwell.onChange((event) => {
210
118
  const isSensorTrigger = event.sensorId.startsWith('trigger:');
211
119
  if (event.state === 'activated') {
212
- // Only write detected/blocked for detection sensors (motion/audio/object).
213
- // Sensor triggers (contact/switch/light) manage their own properties.
120
+ // sensor triggers (contact/switch/light) manage their own detected/blocked
214
121
  if (!isSensorTrigger) {
215
122
  this.writeSensorProperties(event.sensorId, {
216
123
  detected: true,
@@ -230,8 +137,204 @@ let DetectionCoordinator = class DetectionCoordinator {
230
137
  }
231
138
  });
232
139
  }
140
+ updateZones(zones) {
141
+ this.config.zones = zones;
142
+ this.pipeline.updateZones(zones);
143
+ }
144
+ updateLines(lines) {
145
+ this.config.lines = lines;
146
+ this.pipeline.updateLines(lines, this.videoAspectRatio);
147
+ }
148
+ updateDetectionSettings(settings) {
149
+ this.config.detectionSettings = settings;
150
+ this.pipeline.updateSettings(settings);
151
+ }
152
+ updatePtzAutotrackSettings(settings) {
153
+ this.config.ptzAutotrack = settings;
154
+ this.ptzAutotracker.updateSettings(settings);
155
+ }
156
+ updateFrameWorkerSettings(settings) {
157
+ this.config.frameWorkerSettings = settings;
158
+ this.thumbnailer.sync(settings.hqSnapshots === true);
159
+ }
160
+ updateInterfaceSettings(settings) {
161
+ this.config.interfaceSettings = settings;
162
+ if (this.config.lines.length > 0) {
163
+ this.pipeline.updateLines(this.config.lines, this.videoAspectRatio);
164
+ }
165
+ }
166
+ async dispose() {
167
+ await this.stopVideoLoop();
168
+ await this.audioLoop.stop();
169
+ await this.thumbnailer.stop();
170
+ this.ptzAutotracker.dispose();
171
+ this.cascadeUnsubscribe?.();
172
+ this.dwellUnsubscribe?.();
173
+ this.cascade.dispose();
174
+ this.dwell.dispose();
175
+ this.eventManager.destroy();
176
+ this.adHocVideoLoop = false;
177
+ this.frameScaler.dispose();
178
+ this.pipeline.cleanup();
179
+ this.currentDetectionState = {};
180
+ }
181
+ async onSensorAdded(sensor) {
182
+ if (DETECTION_SENSOR_TYPES.has(sensor.sensorType) && sensor.requiresFrames) {
183
+ await this.registerDetectionPluginInternal(sensor);
184
+ }
185
+ if (sensor.sensorType === SensorType.PTZ) {
186
+ await this.ptzAutotracker.bind({
187
+ pluginId: sensor.pluginId,
188
+ sensorId: sensor.sensorId,
189
+ capabilities: sensor.capabilities,
190
+ });
191
+ }
192
+ }
193
+ async onSensorRemoved(sensorId) {
194
+ await this.removeDetectionPluginBySensor(sensorId);
195
+ this.ptzAutotracker.unbind(sensorId);
196
+ const dwellKey = `trigger:${sensorId}`;
197
+ this.activeSensorTriggerTypes.delete(dwellKey);
198
+ this.dwell.clear(dwellKey);
199
+ this.dwell.clear(sensorId);
200
+ }
201
+ onSensorCapabilitiesChanged(sensorId, capabilities) {
202
+ this.ptzAutotracker.setCapabilities(sensorId, capabilities);
203
+ }
204
+ async reportSensorWrite(sensorId, sensorType, properties) {
205
+ if (sensorType === SensorType.Object) {
206
+ const filtered = this.applyExternalDetectionFilters(sensorType, properties);
207
+ // presence feed keeps the autotracker's return-home timer alive even while
208
+ // suppression gates everything below
209
+ const externalDetections = filtered.detections ?? [];
210
+ this.ptzAutotracker.handlePresenceDetections(externalDetections);
211
+ if (this.ptzAutotracker.suppressionActive)
212
+ return;
213
+ this.ingestDetectionResult(SensorType.Object, sensorId, filtered);
214
+ this.eventManager.processResults(this.buildSnapshot());
215
+ if (!this.plugins.hasFrameBasedSecondary()) {
216
+ // re-shoot the event thumbnail: smart-camera reports arrive after the
217
+ // motion-start shot, often before the subject fully entered the frame
218
+ if (filtered.detected === true && this.eventManager.hasActiveEvent()) {
219
+ this.thumbnailer.fetchEventThumbnailAsync();
220
+ }
221
+ return;
222
+ }
223
+ if (this.processingExternalSecondary)
224
+ return; // previous RPC still running
225
+ this.processingExternalSecondary = true;
226
+ try {
227
+ // processExternal only adapts the shape, external boxes get no real tracking
228
+ const rawExternal = filtered.detections ?? [];
229
+ const objectDetections = this.pipeline.processExternal(rawExternal);
230
+ const peeked = this.frameSource.peekLatestFrame();
231
+ if (peeked) {
232
+ try {
233
+ const results = { timestamp: Date.now() };
234
+ await this.runSecondariesAndThumbnails(peeked.frame, objectDetections, results);
235
+ this.ingestResultsForAllSecondaries(results);
236
+ const snapshot = this.buildSnapshot();
237
+ if (results.thumbnails && results.thumbnails.length > 0) {
238
+ snapshot.thumbnails = results.thumbnails;
239
+ }
240
+ this.eventManager.processResults(snapshot);
241
+ }
242
+ finally {
243
+ await peeked[Symbol.asyncDispose]();
244
+ }
245
+ return;
246
+ }
247
+ const fetched = await this.frameSource.fetchSnapshotFrame();
248
+ if (!fetched)
249
+ return;
250
+ try {
251
+ const results = { timestamp: Date.now() };
252
+ const sceneJpeg = await this.secondaries.detectFullFrame(fetched.frame, results);
253
+ this.ingestResultsForAllSecondaries(results);
254
+ const snapshot = this.buildSnapshot();
255
+ if (sceneJpeg)
256
+ snapshot.eventThumbnail = sceneJpeg;
257
+ const wantsEventThumb = this.eventManager.needsThumbnail() || this.snapshotWillStartEvent(snapshot);
258
+ this.eventManager.processResults(snapshot);
259
+ if (wantsEventThumb) {
260
+ this.thumbnailer.upgradeEventThumbnailAsync();
261
+ }
262
+ }
263
+ finally {
264
+ await fetched[Symbol.asyncDispose]();
265
+ }
266
+ }
267
+ finally {
268
+ this.processingExternalSecondary = false;
269
+ }
270
+ return;
271
+ }
272
+ if (sensorType === SensorType.Motion) {
273
+ // frame-diff is garbage while the PTZ repositions
274
+ if (this.ptzAutotracker.suppressionActive)
275
+ return;
276
+ const filtered = this.applyExternalDetectionFilters(sensorType, properties);
277
+ this.ingestDetectionResult(SensorType.Motion, sensorId, filtered);
278
+ this.eventManager.processResults(this.buildSnapshot());
279
+ this.thumbnailer.fetchEventThumbnailAsync();
280
+ return;
281
+ }
282
+ // classifier is multi-provider, buffer keying needs the owning pluginId
283
+ let pluginId;
284
+ if (sensorType === SensorType.Classifier) {
285
+ pluginId = this.plugins.getAll(SensorType.Classifier).find((p) => p.sensorId === sensorId)?.pluginId;
286
+ }
287
+ const filtered = this.applyExternalDetectionFilters(sensorType, properties);
288
+ this.ingestDetectionResult(sensorType, sensorId, filtered, pluginId);
289
+ this.eventManager.processResults(this.buildSnapshot());
290
+ }
291
+ reportSensorTrigger(sensorId, triggerType, action, _sustained, timeoutSeconds) {
292
+ // namespaced so trigger dwells don't overwrite the sensor's own detected/blocked
293
+ const dwellKey = `trigger:${sensorId}`;
294
+ if (action === 'activate') {
295
+ this.activeSensorTriggerTypes.set(dwellKey, triggerType);
296
+ }
297
+ this.dwell.refresh(dwellKey, timeoutSeconds);
298
+ if (action === 'activate' && this.cascadeEnabled) {
299
+ this.cascade.triggerMomentary(this.cascadeTimeoutSeconds);
300
+ }
301
+ this.eventManager.processResults(this.buildSnapshot());
302
+ // no loop frame to encode from, fetch async (no-op if already captured)
303
+ if (action === 'activate') {
304
+ this.thumbnailer.fetchEventThumbnailAsync();
305
+ }
306
+ }
307
+ reconcileSensorTriggers(activeSensorIds) {
308
+ const activeSet = new Set(activeSensorIds);
309
+ for (const dwellKey of this.activeSensorTriggerTypes.keys()) {
310
+ const sensorId = dwellKey.slice('trigger:'.length);
311
+ if (!activeSet.has(sensorId)) {
312
+ this.activeSensorTriggerTypes.delete(dwellKey);
313
+ this.dwell.clear(dwellKey);
314
+ }
315
+ }
316
+ }
317
+ hasPlugin(sensorType) {
318
+ return this.plugins.has(sensorType);
319
+ }
320
+ get cascadeEnabled() {
321
+ return this.config.detectionSettings.cascadeDetection !== false;
322
+ }
323
+ get cascadeTimeoutSeconds() {
324
+ return this.config.detectionSettings.cascadeTimeout ?? DEFAULT_CASCADE_TIMEOUT;
325
+ }
326
+ get motionResolution() {
327
+ return this.config.detectionSettings.motion.resolution;
328
+ }
329
+ get targetFps() {
330
+ return this.frameSource.fps;
331
+ }
332
+ get videoAspectRatio() {
333
+ const [w, h] = this.config.interfaceSettings.aspectRatio.split(':').map(Number);
334
+ return w / h;
335
+ }
233
336
  handleCascadeDeactivated() {
234
- // Clear cascade-gated buffer slots but keep motion/audio intact
337
+ // clear cascade-gated buffer slots, keep motion/audio
235
338
  const cs = this.currentDetectionState;
236
339
  cs.object = undefined;
237
340
  cs.face = undefined;
@@ -240,31 +343,46 @@ let DetectionCoordinator = class DetectionCoordinator {
240
343
  cs.clip = undefined;
241
344
  cs.lineCrossings = undefined;
242
345
  cs.cascadeTriggered = undefined;
243
- // Publish empty detections for all cascade-gated sensors so UI clears bboxes
346
+ // publish empty detections so the UI clears stale bboxes
244
347
  const clearTargets = [];
245
- for (const [type, plugin] of this.singlePlugins) {
246
- if (type === SensorType.Object || type === SensorType.Face || type === SensorType.LicensePlate) {
247
- clearTargets.push({ sensorId: plugin.sensorId, type });
248
- }
348
+ for (const type of [SensorType.Object, SensorType.Face, SensorType.LicensePlate]) {
349
+ const plugin = this.plugins.get(type);
350
+ if (plugin)
351
+ clearTargets.push(plugin.sensorId);
249
352
  }
250
- const classifierPlugins = this.multiPlugins.get(SensorType.Classifier);
251
- if (classifierPlugins) {
252
- for (const plugin of classifierPlugins.values()) {
253
- clearTargets.push({ sensorId: plugin.sensorId, type: SensorType.Classifier });
254
- }
353
+ for (const plugin of this.plugins.getAll(SensorType.Classifier)) {
354
+ clearTargets.push(plugin.sensorId);
255
355
  }
256
- for (const { sensorId } of clearTargets) {
356
+ for (const sensorId of clearTargets) {
257
357
  this.writeSensorProperties(sensorId, { detected: false, detections: [] });
358
+ this.secondaryBboxSeen.delete(sensorId);
258
359
  }
259
- // Tick EventManager — segment ends but event continues (motion still active)
360
+ // segment ends here, the event itself continues while motion is active
260
361
  this.eventManager.processResults(this.buildSnapshot());
261
362
  }
262
363
  handleEventEnded() {
263
- this.pipeline.cleanup();
264
- this.seenStationaryTracks.clear();
364
+ if (this.config.detectionSettings.object.suppressStatic ?? true) {
365
+ this.stationary.retainAcrossEvent((trackIds) => this.pipeline.retainTracks(trackIds));
366
+ }
367
+ else {
368
+ this.pipeline.cleanup();
369
+ this.stationary.clear();
370
+ }
371
+ this.stationary.resetEventState();
265
372
  this.activeSensorTriggerTypes.clear();
266
373
  this.currentDetectionState = {};
267
374
  }
375
+ handleAudioLoopResult(sensorId, result) {
376
+ // only the cascade trigger lives here, the audio dwell is handled in ingest
377
+ if (result.detected && this.cascadeEnabled) {
378
+ this.cascade.triggerMomentary(this.config.detectionSettings.audio.timeout);
379
+ }
380
+ this.ingestDetectionResult(SensorType.Audio, sensorId, {
381
+ detected: result.detected,
382
+ detections: result.detections ?? [],
383
+ ...(result.decibels !== undefined ? { decibels: result.decibels } : {}),
384
+ });
385
+ }
268
386
  writeSensorProperties(sensorId, properties) {
269
387
  const msg = {
270
388
  sensorId,
@@ -314,10 +432,8 @@ let DetectionCoordinator = class DetectionCoordinator {
314
432
  const raw = properties.detections;
315
433
  if (!Array.isArray(raw) || raw.length === 0)
316
434
  return properties;
317
- // Smart-camera plugins may report label-only detections without
318
- // coordinates (current SDKs substitute full-frame, but older or
319
- // third-party plugins can still send box-less writes) — the zone filter
320
- // and rust merge assume a box on every detection.
435
+ // older/third-party plugins can send box-less detections, but the zone
436
+ // filter and rust merge assume a box on every detection
321
437
  const detections = raw.map((detection) => (detection.box ? detection : { ...detection, box: { x: 0, y: 0, width: 1, height: 1 } }));
322
438
  let filtered;
323
439
  switch (sensorType) {
@@ -333,7 +449,7 @@ let DetectionCoordinator = class DetectionCoordinator {
333
449
  filtered = this.pipeline.runZoneFilter(detections);
334
450
  break;
335
451
  default:
336
- return { ...properties, detections }; // Audio etc no zone filter
452
+ return { ...properties, detections }; // audio etc, no zone filter
337
453
  }
338
454
  return {
339
455
  ...properties,
@@ -341,13 +457,10 @@ let DetectionCoordinator = class DetectionCoordinator {
341
457
  detected: filtered.length > 0,
342
458
  };
343
459
  }
344
- // PTZ-suppression gating happens upstream callers decide whether a write
345
- // belongs in the full pipeline or autotracker-only.
460
+ // PTZ-suppression gating happens upstream, callers decide what gets in here
346
461
  ingestDetectionResult(sensorType, sensorId, properties, pluginId) {
347
462
  this.updateBufferForType(sensorType, properties, pluginId);
348
463
  const detected = properties.detected === true;
349
- // Motion/Audio: dwell drives the detected/blocked/lastTriggered lifecycle
350
- // entirely (see dwell.onChange in the constructor); motion also cascades.
351
464
  if (sensorType === SensorType.Motion && detected) {
352
465
  const motionTimeout = this.config.detectionSettings.motion.timeout;
353
466
  this.dwell.refresh(sensorId, motionTimeout);
@@ -363,51 +476,25 @@ let DetectionCoordinator = class DetectionCoordinator {
363
476
  }
364
477
  }
365
478
  else if (sensorType === SensorType.Object && detected) {
366
- // Object dwell anti-chatter: a single missed frame must not split the
367
- // event. Stationary tracks refresh the dwell only ONCE (first sighting);
368
- // afterwards they're in seenStationaryTracks and don't count, so parked
369
- // cars/bikes can't keep events alive forever. Moving tracks always count.
370
- // The set is cleared on cascade deactivate (event boundary).
479
+ // the dwell bridges single missed frames; anchored stationary tracks
480
+ // don't refresh it, so parked objects can't hold events open
371
481
  const detections = properties.detections ?? [];
372
- let hasActiveTrack = false;
373
- for (const t of detections) {
374
- if (t.trackLost)
375
- continue;
376
- // No trackId = external smart-camera write (edge-triggered presence
377
- // report, no tracking) — there is no speed/identity to judge
378
- // stationarity by, so it always counts as active.
379
- if (t.trackId === undefined) {
380
- hasActiveTrack = true;
381
- continue;
382
- }
383
- const isStationary = (t.trackSpeed ?? 0) < STATIONARY_SPEED_THRESHOLD;
384
- if (isStationary) {
385
- if (t.trackId !== undefined && !this.seenStationaryTracks.has(t.trackId)) {
386
- this.seenStationaryTracks.add(t.trackId);
387
- hasActiveTrack = true;
388
- }
389
- }
390
- else {
391
- hasActiveTrack = true;
392
- }
393
- }
394
- if (hasActiveTrack) {
482
+ if (this.stationary.evaluate(detections)) {
395
483
  this.dwell.refresh(sensorId, OBJECT_DWELL_SECONDS);
396
484
  }
397
485
  else {
398
- // Only known stationary tracks suppress detected and clear buffered
399
- // detections so the EventManager doesn't open a new segment; the UI
400
- // still gets the bboxes via the property publish below.
486
+ // clear detected + buffer so no segment opens, the UI still gets
487
+ // the bboxes via the property publish below
401
488
  properties.detected = false;
402
489
  if (this.currentDetectionState.object) {
403
490
  this.currentDetectionState.object.detected = false;
404
491
  this.currentDetectionState.object.detections = [];
405
492
  }
493
+ this.stationary.logSuppressedOnce(detections);
406
494
  }
407
495
  }
408
- // Dwell is the SOLE owner of detected/blocked/lastTriggered for these
409
- // types — a "no detection" frame must not overwrite the dwell-set state,
410
- // so strip them from the publish; dwell.onChange is the only writer.
496
+ // dwell owns detected/blocked/lastTriggered for these types, a no-detection
497
+ // frame would overwrite the dwell-set state, so strip before publishing
411
498
  let publishProps = properties;
412
499
  if (sensorType === SensorType.Motion || sensorType === SensorType.Audio || sensorType === SensorType.Object) {
413
500
  const { detected: _d, blocked: _b, lastTriggered: _t, ...rest } = properties;
@@ -416,7 +503,16 @@ let DetectionCoordinator = class DetectionCoordinator {
416
503
  if (Object.keys(publishProps).length > 0) {
417
504
  this.writeSensorProperties(sensorId, publishProps);
418
505
  }
419
- // EventManager is NOT ticked here processDetection flushes once per frame.
506
+ if (sensorType === SensorType.Face || sensorType === SensorType.LicensePlate || sensorType === SensorType.Classifier) {
507
+ const detections = properties.detections ?? [];
508
+ if (detections.length > 0) {
509
+ this.secondaryBboxSeen.set(sensorId, Date.now());
510
+ }
511
+ else {
512
+ this.secondaryBboxSeen.delete(sensorId);
513
+ }
514
+ }
515
+ // no EventManager tick here, processDetection flushes once per frame
420
516
  }
421
517
  updateBufferForType(sensorType, properties, pluginId) {
422
518
  const cs = this.currentDetectionState;
@@ -453,381 +549,104 @@ let DetectionCoordinator = class DetectionCoordinator {
453
549
  break;
454
550
  }
455
551
  }
456
- updateZones(zones) {
457
- this.config.zones = zones;
458
- this.pipeline.updateZones(zones);
459
- }
460
- updateLines(lines) {
461
- this.config.lines = lines;
462
- this.pipeline.updateLines(lines, this.videoAspectRatio);
463
- }
464
- updateDetectionSettings(settings) {
465
- this.config.detectionSettings = settings;
466
- this.pipeline.updateSettings(settings);
467
- }
468
- updatePtzAutotrackSettings(settings) {
469
- this.config.ptzAutotrack = settings;
470
- this.ptzAutotracker.updateSettings(settings);
471
- }
472
- updateFrameWorkerSettings(settings) {
473
- this.config.frameWorkerSettings = settings;
474
- this.syncHqSnapshotSource();
475
- }
476
- updateInterfaceSettings(settings) {
477
- this.config.interfaceSettings = settings;
478
- if (this.config.lines.length > 0) {
479
- this.pipeline.updateLines(this.config.lines, this.videoAspectRatio);
480
- }
481
- }
482
- async dispose() {
483
- await this.stopVideoLoop();
484
- await this.stopAudioLoop();
485
- await this.hqSnapshotSource?.stop();
486
- this.ptzAutotracker.dispose();
487
- this.cascadeUnsubscribe?.();
488
- this.dwellUnsubscribe?.();
489
- this.cascade.dispose();
490
- this.dwell.dispose();
491
- this.eventManager.destroy();
492
- this.adHocVideoLoop = false;
493
- this.frameScaler.dispose();
494
- this.pipeline.cleanup();
495
- this.currentDetectionState = {};
496
- }
497
- async onSensorAdded(sensor) {
498
- if (DETECTION_SENSOR_TYPES.has(sensor.sensorType) && sensor.requiresFrames) {
499
- await this.registerDetectionPluginInternal(sensor);
500
- }
501
- if (sensor.sensorType === SensorType.PTZ) {
502
- await this.ptzAutotracker.bind({
503
- pluginId: sensor.pluginId,
504
- sensorId: sensor.sensorId,
505
- capabilities: sensor.capabilities,
506
- });
507
- }
508
- }
509
- async onSensorRemoved(sensorId) {
510
- await this.removeDetectionPluginBySensor(sensorId);
511
- this.ptzAutotracker.unbind(sensorId);
512
- const dwellKey = `trigger:${sensorId}`;
513
- this.activeSensorTriggerTypes.delete(dwellKey);
514
- this.dwell.clear(dwellKey);
515
- this.dwell.clear(sensorId);
516
- }
517
- onSensorCapabilitiesChanged(sensorId, capabilities) {
518
- this.ptzAutotracker.setCapabilities(sensorId, capabilities);
519
- }
520
- async registerDetectionPluginInternal(sensor) {
521
- // Wait for any in-progress stop to complete before starting a new loop
522
- if (this.videoStopPromise)
523
- await this.videoStopPromise;
524
- if (this.audioStopPromise)
525
- await this.audioStopPromise;
526
- const wasVideoNeeded = this.shouldVideoBeActive();
527
- const wasAudioNeeded = this.shouldAudioBeActive();
528
- const namespaces = NamespaceManager.sensorProviderNamespaces(sensor.pluginId, this.config.cameraId, sensor.sensorId);
529
- const sensorProxy = this.proxy.createProxy(namespaces.sensorRpc);
530
- const pluginInfo = {
531
- pluginId: sensor.pluginId,
532
- sensorId: sensor.sensorId,
533
- sensorType: sensor.sensorType,
534
- requiresFrames: sensor.requiresFrames,
535
- modelSpec: sensor.modelSpec,
536
- proxy: sensorProxy,
537
- };
538
- if (this.isMultiProvider(sensor.sensorType)) {
539
- if (!this.multiPlugins.has(sensor.sensorType)) {
540
- this.multiPlugins.set(sensor.sensorType, new Map());
541
- }
542
- const typePlugins = this.multiPlugins.get(sensor.sensorType);
543
- if (typePlugins.has(sensor.pluginId))
544
- return;
545
- typePlugins.set(sensor.pluginId, pluginInfo);
546
- this.logger.trace(`Multi-provider plugin registered: ${sensor.pluginId} for ${sensor.sensorType}`);
547
- }
548
- else {
549
- if (this.singlePlugins.has(sensor.sensorType))
550
- return;
551
- this.singlePlugins.set(sensor.sensorType, pluginInfo);
552
- this.logger.trace(`Plugin registered: ${sensor.pluginId} for ${sensor.sensorType}`);
553
- }
554
- if (!wasVideoNeeded && this.shouldVideoBeActive()) {
555
- this.adHocVideoLoop = false;
556
- this.startVideoLoop();
557
- }
558
- if (!wasAudioNeeded && this.shouldAudioBeActive()) {
559
- this.startAudioLoop();
560
- }
561
- if (this.cascade.isActive) {
562
- this.startAdHocVideoLoopIfNeeded();
563
- }
552
+ async registerDetectionPluginInternal(sensor) {
553
+ if (this.videoStopPromise)
554
+ await this.videoStopPromise;
555
+ await this.audioLoop.waitForStop();
556
+ const wasVideoNeeded = this.plugins.shouldVideoBeActive();
557
+ const wasAudioNeeded = this.plugins.shouldAudioBeActive();
558
+ const namespaces = NamespaceManager.sensorProviderNamespaces(sensor.pluginId, this.config.cameraId, sensor.sensorId);
559
+ const sensorProxy = this.proxy.createProxy(namespaces.sensorRpc);
560
+ const registered = this.plugins.register({
561
+ pluginId: sensor.pluginId,
562
+ sensorId: sensor.sensorId,
563
+ sensorType: sensor.sensorType,
564
+ requiresFrames: sensor.requiresFrames,
565
+ modelSpec: sensor.modelSpec,
566
+ proxy: sensorProxy,
567
+ });
568
+ if (!registered)
569
+ return;
570
+ this.logger.trace(`Plugin registered: ${sensor.pluginId} for ${sensor.sensorType}`);
571
+ if (!wasVideoNeeded && this.plugins.shouldVideoBeActive()) {
572
+ this.adHocVideoLoop = false;
573
+ this.startVideoLoop();
574
+ }
575
+ if (!wasAudioNeeded && this.plugins.shouldAudioBeActive()) {
576
+ this.audioLoop.start();
577
+ }
578
+ if (this.cascade.isActive) {
579
+ this.startAdHocVideoLoopIfNeeded();
580
+ }
564
581
  }
565
582
  async removeDetectionPluginBySensor(sensorId) {
566
- const wasVideoNeeded = this.shouldVideoBeActive();
567
- const wasAudioNeeded = this.shouldAudioBeActive();
568
- let removed = false;
569
- for (const [sensorType, plugin] of this.singlePlugins) {
570
- if (plugin.sensorId === sensorId) {
571
- this.singlePlugins.delete(sensorType);
572
- removed = true;
573
- this.logger.trace(`Plugin unregistered: ${plugin.pluginId} (${sensorType})`);
574
- }
575
- }
576
- for (const [sensorType, typePlugins] of this.multiPlugins) {
577
- for (const [pluginId, plugin] of typePlugins) {
578
- if (plugin.sensorId === sensorId) {
579
- typePlugins.delete(pluginId);
580
- removed = true;
581
- this.logger.trace(`Multi-provider plugin unregistered: ${pluginId} (${sensorType})`);
582
- }
583
- }
584
- if (typePlugins.size === 0)
585
- this.multiPlugins.delete(sensorType);
586
- }
587
- if (!removed)
583
+ const wasVideoNeeded = this.plugins.shouldVideoBeActive();
584
+ const wasAudioNeeded = this.plugins.shouldAudioBeActive();
585
+ const removed = this.plugins.removeBySensor(sensorId);
586
+ if (removed.length === 0)
588
587
  return;
589
- if (!this.singlePlugins.has(SensorType.Object)) {
588
+ for (const plugin of removed) {
589
+ this.logger.trace(`Plugin unregistered: ${plugin.pluginId} (${plugin.sensorType})`);
590
+ }
591
+ if (!this.plugins.has(SensorType.Object)) {
590
592
  this.adHocVideoLoop = false;
591
593
  }
592
- // Hot-reload: uses needsAdHocLoop() (not shouldVideoBeActive) so a loop
593
- // still needed by a frame-based object plugin isn't stopped accidentally.
594
- if (this.adHocVideoLoop && !this.needsAdHocLoop()) {
594
+ // needsAdHocLoop, not shouldVideoBeActive: a loop still needed by a
595
+ // frame-based object plugin must survive plugin hot-reloads
596
+ if (this.adHocVideoLoop && !this.plugins.needsAdHocLoop()) {
595
597
  this.adHocVideoLoop = false;
596
598
  this.logger.debug('Stopping ad-hoc video loop — last frame-based consumer removed');
597
599
  this.stopVideoLoop();
598
600
  }
599
- // Await to prevent race: a rapid re-register could start a second loop
600
- // while the old one is still shutting down.
601
- if (wasVideoNeeded && !this.shouldVideoBeActive() && !this.adHocVideoLoop) {
601
+ // await, otherwise a rapid re-register starts a second loop while the
602
+ // old one is still shutting down
603
+ if (wasVideoNeeded && !this.plugins.shouldVideoBeActive() && !this.adHocVideoLoop) {
602
604
  await this.stopVideoLoop();
603
605
  }
604
- if (wasAudioNeeded && !this.shouldAudioBeActive()) {
605
- await this.stopAudioLoop();
606
- }
607
- }
608
- async reportSensorWrite(sensorId, sensorType, properties) {
609
- if (sensorType === SensorType.Object) {
610
- const filtered = this.applyExternalDetectionFilters(sensorType, properties);
611
- // Always feed the autotracker (even during suppression, so it can decide
612
- // when to stop). External writes are presence-only — the presence channel
613
- // holds the return-to-home timer without driving the motor.
614
- const externalDetections = filtered.detections ?? [];
615
- this.ptzAutotracker.handlePresenceDetections(externalDetections);
616
- // During suppression tracker/pipeline/events stay gated — no stale
617
- // bboxes or false events from the camera pose change.
618
- if (this.ptzAutotracker.suppressionActive)
619
- return;
620
- this.ingestDetectionResult(SensorType.Object, sensorId, filtered);
621
- this.eventManager.processResults(this.buildSnapshot());
622
- if (!this.hasFrameBasedSecondary()) {
623
- // Smart-camera objects arrive after the motion-start thumbnail was
624
- // shot — usually before the subject fully entered the frame. Re-shoot
625
- // so the event picture actually shows what was detected.
626
- if (filtered.detected === true && this.eventManager.hasActiveEvent()) {
627
- this.fetchEventThumbnailAsync();
628
- }
629
- return;
630
- }
631
- if (this.processingExternalSecondary)
632
- return; // busy-skip: previous RPC still running
633
- this.processingExternalSecondary = true;
634
- try {
635
- // Wrap external boxes as TrackedDetection (shape adapter, no real
636
- // tracking) so the secondary pipeline treats them like loop output.
637
- const rawExternal = filtered.detections ?? [];
638
- const objectDetections = this.pipeline.processExternal(rawExternal);
639
- // Fast path: loop is running and has a frame → crop mode.
640
- const peeked = this.frameSource.peekLatestFrame();
641
- if (peeked) {
642
- try {
643
- const results = { timestamp: Date.now() };
644
- await this.runSecondariesAndThumbnails(peeked.frame, objectDetections, results);
645
- this.ingestResultsForAllSecondaries(results);
646
- const snapshot = this.buildSnapshot();
647
- if (results.thumbnails && results.thumbnails.length > 0) {
648
- snapshot.thumbnails = results.thumbnails;
649
- }
650
- this.eventManager.processResults(snapshot);
651
- }
652
- finally {
653
- await peeked[Symbol.asyncDispose]();
654
- }
655
- return;
656
- }
657
- // Slow path: no loop frame yet → snapshot fetch, secondaries run
658
- // full-frame (no per-detection crops).
659
- const fetched = await this.frameSource.fetchSnapshotFrame();
660
- if (!fetched)
661
- return;
662
- try {
663
- const results = { timestamp: Date.now() };
664
- const sceneJpeg = await this.runSecondariesFullFrame(fetched.frame, results);
665
- this.ingestResultsForAllSecondaries(results);
666
- const snapshot = this.buildSnapshot();
667
- if (sceneJpeg)
668
- snapshot.eventThumbnail = sceneJpeg;
669
- const wantsEventThumb = this.eventManager.needsThumbnail() || this.snapshotWillStartEvent(snapshot);
670
- this.eventManager.processResults(snapshot);
671
- if (wantsEventThumb) {
672
- this.upgradeEventThumbnailAsync();
673
- }
674
- }
675
- finally {
676
- await fetched[Symbol.asyncDispose]();
677
- }
678
- }
679
- finally {
680
- this.processingExternalSecondary = false;
681
- }
682
- return;
683
- }
684
- if (sensorType === SensorType.Motion) {
685
- // Fully gated during suppression — frame-diff is fake while the camera repositions.
686
- if (this.ptzAutotracker.suppressionActive)
687
- return;
688
- const filtered = this.applyExternalDetectionFilters(sensorType, properties);
689
- this.ingestDetectionResult(SensorType.Motion, sensorId, filtered);
690
- this.eventManager.processResults(this.buildSnapshot());
691
- this.fetchEventThumbnailAsync();
692
- return;
693
- }
694
- // External secondaries: direct ingest, no pixel work. Classifier (multi-
695
- // provider) needs the owning pluginId for buffer keying.
696
- let pluginId;
697
- if (sensorType === SensorType.Classifier) {
698
- const typePlugins = this.multiPlugins.get(SensorType.Classifier);
699
- if (typePlugins) {
700
- for (const plugin of typePlugins.values()) {
701
- if (plugin.sensorId === sensorId) {
702
- pluginId = plugin.pluginId;
703
- break;
704
- }
705
- }
706
- }
707
- }
708
- const filtered = this.applyExternalDetectionFilters(sensorType, properties);
709
- this.ingestDetectionResult(sensorType, sensorId, filtered, pluginId);
710
- this.eventManager.processResults(this.buildSnapshot());
711
- }
712
- reportSensorTrigger(sensorId, triggerType, action, _sustained, timeoutSeconds) {
713
- // Namespaced dwell key so trigger dwells don't overwrite the sensor's own
714
- // detected/blocked properties (contact/switch/light manage those themselves).
715
- const dwellKey = `trigger:${sensorId}`;
716
- if (action === 'activate') {
717
- this.activeSensorTriggerTypes.set(dwellKey, triggerType);
718
- }
719
- this.dwell.refresh(dwellKey, timeoutSeconds);
720
- if (action === 'activate' && this.cascadeEnabled) {
721
- this.cascade.triggerMomentary(this.cascadeTimeoutSeconds);
722
- }
723
- this.eventManager.processResults(this.buildSnapshot());
724
- // Trigger-only path (doorbell, contact) has no loop frame to encode from —
725
- // fetch the event thumb async; no-op if one was already captured.
726
- if (action === 'activate') {
727
- this.fetchEventThumbnailAsync();
728
- }
729
- }
730
- reconcileSensorTriggers(activeSensorIds) {
731
- const activeSet = new Set(activeSensorIds);
732
- for (const dwellKey of this.activeSensorTriggerTypes.keys()) {
733
- const sensorId = dwellKey.slice('trigger:'.length);
734
- if (!activeSet.has(sensorId)) {
735
- this.activeSensorTriggerTypes.delete(dwellKey);
736
- this.dwell.clear(dwellKey);
737
- }
606
+ if (wasAudioNeeded && !this.plugins.shouldAudioBeActive()) {
607
+ await this.audioLoop.stop();
738
608
  }
739
609
  }
740
- clearMotionStateForSuppression() {
610
+ handleSuppressionActivated() {
741
611
  const cs = this.currentDetectionState;
742
612
  cs.motion = undefined;
743
- const motionPlugin = this.singlePlugins.get(SensorType.Motion);
744
- if (motionPlugin) {
613
+ cs.object = undefined;
614
+ // motion is garbage during ego-motion and face/lpd inference pauses, their
615
+ // boxes would float frozen through the pan; object keeps publishing live
616
+ const motionPlugin = this.plugins.get(SensorType.Motion);
617
+ if (motionPlugin)
745
618
  this.writeSensorProperties(motionPlugin.sensorId, { detections: [] });
619
+ for (const type of [SensorType.Face, SensorType.LicensePlate]) {
620
+ const plugin = this.plugins.get(type);
621
+ if (plugin)
622
+ this.clearSecondaryBboxes(plugin.sensorId);
746
623
  }
747
- }
748
- hasPlugin(sensorType) {
749
- if (this.isMultiProvider(sensorType)) {
750
- const typePlugins = this.multiPlugins.get(sensorType);
751
- return typePlugins !== undefined && typePlugins.size > 0;
624
+ for (const plugin of this.plugins.getAll(SensorType.Classifier)) {
625
+ this.clearSecondaryBboxes(plugin.sensorId);
752
626
  }
753
- return this.singlePlugins.has(sensorType);
627
+ // image-space anchors don't survive a camera move, they re-form after
628
+ this.stationary.dropForCameraMove();
754
629
  }
755
- getPlugin(sensorType) {
756
- return this.singlePlugins.get(sensorType);
757
- }
758
- getPluginsForType(sensorType) {
759
- if (this.isMultiProvider(sensorType)) {
760
- const typePlugins = this.multiPlugins.get(sensorType);
761
- return typePlugins ? Array.from(typePlugins.values()) : [];
762
- }
763
- const plugin = this.singlePlugins.get(sensorType);
764
- return plugin ? [plugin] : [];
630
+ clearSecondaryBboxes(sensorId) {
631
+ this.writeSensorProperties(sensorId, { detections: [] });
632
+ this.secondaryBboxSeen.delete(sensorId);
765
633
  }
766
- getPlugins() {
767
- const allPlugins = [];
768
- for (const plugin of this.singlePlugins.values())
769
- allPlugins.push(plugin);
770
- for (const typePlugins of this.multiPlugins.values()) {
771
- for (const plugin of typePlugins.values())
772
- allPlugins.push(plugin);
773
- }
774
- return allPlugins;
775
- }
776
- hasAnyPlugins() {
777
- if (this.singlePlugins.size > 0)
778
- return true;
779
- for (const typePlugins of this.multiPlugins.values()) {
780
- if (typePlugins.size > 0)
781
- return true;
634
+ clearStaleSecondaryBboxes() {
635
+ if (this.secondaryBboxSeen.size === 0)
636
+ return;
637
+ const now = Date.now();
638
+ for (const [sensorId, ts] of this.secondaryBboxSeen) {
639
+ if (now - ts > SECONDARY_BBOX_TTL_MS) {
640
+ this.clearSecondaryBboxes(sensorId);
641
+ }
782
642
  }
783
- return false;
784
- }
785
- isRunning() {
786
- return this.loopRunning;
787
- }
788
- isStreaming() {
789
- return this.frameSource.isStreaming;
790
- }
791
- hasSecondaryModelSpec(spec) {
792
- return spec !== undefined && 'triggerLabels' in spec && Array.isArray(spec.triggerLabels);
793
- }
794
- isVideoInputSpec(spec) {
795
- return spec != null && typeof spec === 'object' && 'width' in spec && 'height' in spec && 'format' in spec;
796
- }
797
- isOnEdge(box) {
798
- return box.x < 0.02 || box.y < 0.02 || box.x + box.width > 0.98 || box.y + box.height > 0.98;
799
- }
800
- isAudioModelSpec(spec) {
801
- return spec !== undefined && 'input' in spec && typeof spec.input === 'object' && 'sampleRate' in spec.input;
802
- }
803
- isMultiProvider(sensorType) {
804
- return SENSOR_TYPE_CONFIG[sensorType]?.multiProvider === true;
805
- }
806
- // Video stream is permanently needed when: (1) frame-based motion detection, or
807
- // (2) audio plugin + object plugin for continuous audio-triggered cascade.
808
- // External motion (requiresFrames=false) and sensor cascade triggers use an ad-hoc
809
- // video loop — started on-demand, stopped when cascade ends. This prevents blocking
810
- // event-based cameras (e.g. Ring) that stop firing events while the stream is consumed.
811
- shouldVideoBeActive() {
812
- const motionPlugin = this.singlePlugins.get(SensorType.Motion);
813
- const objectPlugin = this.singlePlugins.get(SensorType.Object);
814
- const audioPlugin = this.singlePlugins.get(SensorType.Audio);
815
- if (motionPlugin?.requiresFrames)
816
- return true;
817
- if (audioPlugin && objectPlugin?.requiresFrames)
818
- return true;
819
- return false;
820
- }
821
- shouldAudioBeActive() {
822
- const audioPlugin = this.singlePlugins.get(SensorType.Audio);
823
- return audioPlugin?.requiresFrames === true;
824
643
  }
825
644
  startVideoLoop() {
826
645
  if (this.loopRunning || this.videoStopPromise)
827
646
  return;
828
647
  this.logger.debug('Starting video detection loop');
829
648
  this.loopRunning = true;
830
- this.reconnectCount = 0;
649
+ this.videoBackoff.reset();
831
650
  this.loopPromise = this.runDetectionLoop();
832
651
  }
833
652
  async stopVideoLoop() {
@@ -851,318 +670,27 @@ let DetectionCoordinator = class DetectionCoordinator {
851
670
  this.videoStopPromise = undefined;
852
671
  }
853
672
  }
854
- startAudioLoop() {
855
- if (this.audioLoopRunning || this.audioStopPromise)
856
- return;
857
- this.logger.debug('Starting audio detection loop');
858
- this.audioLoopRunning = true;
859
- this.audioReconnectCount = 0;
860
- this.audioLoopPromise = this.runAudioDetectionLoop();
861
- }
862
- async stopAudioLoop() {
863
- if (!this.audioLoopRunning) {
864
- if (this.audioStopPromise)
865
- await this.audioStopPromise;
866
- return;
867
- }
868
- this.logger.debug('Stopping audio detection loop');
869
- this.audioLoopRunning = false;
870
- const doStop = async () => {
871
- await this.audioSource?.stop();
872
- await this.audioLoopPromise;
873
- this.audioLoopPromise = undefined;
874
- };
875
- this.audioStopPromise = doStop();
876
- try {
877
- await this.audioStopPromise;
878
- }
879
- finally {
880
- this.audioStopPromise = undefined;
881
- }
882
- }
883
- hasFrameBasedSecondary() {
884
- const face = this.singlePlugins.get(SensorType.Face);
885
- if (face?.requiresFrames)
886
- return true;
887
- const lpd = this.singlePlugins.get(SensorType.LicensePlate);
888
- if (lpd?.requiresFrames)
889
- return true;
890
- const clip = this.singlePlugins.get(SensorType.Clip);
891
- if (clip?.requiresFrames)
892
- return true;
893
- const classifiers = this.multiPlugins.get(SensorType.Classifier);
894
- if (classifiers) {
895
- for (const p of classifiers.values()) {
896
- if (p.requiresFrames)
897
- return true;
898
- }
899
- }
900
- return false;
901
- }
902
- needsAdHocLoop() {
903
- const obj = this.singlePlugins.get(SensorType.Object);
904
- if (!obj)
905
- return false;
906
- if (obj.requiresFrames)
907
- return true;
908
- return this.hasFrameBasedSecondary();
909
- }
910
673
  startAdHocVideoLoopIfNeeded() {
911
- if (!this.loopRunning && this.needsAdHocLoop()) {
674
+ if (!this.loopRunning && this.plugins.needsAdHocLoop()) {
912
675
  this.adHocVideoLoop = true;
913
676
  this.logger.debug('Starting ad-hoc video loop for sensor cascade trigger');
914
677
  this.startVideoLoop();
915
678
  }
916
679
  }
917
680
  stopAdHocVideoLoopIfIdle() {
918
- if (this.adHocVideoLoop && !this.shouldVideoBeActive()) {
681
+ if (this.adHocVideoLoop && !this.plugins.shouldVideoBeActive()) {
919
682
  this.adHocVideoLoop = false;
920
683
  this.logger.debug('Stopping ad-hoc video loop — cascade ended');
921
684
  this.stopVideoLoop();
922
685
  }
923
686
  }
924
- fetchEventThumbnailAsync() {
925
- void (async () => {
926
- try {
927
- const env_1 = { stack: [], error: void 0, hasError: false };
928
- try {
929
- const hqJpeg = await this.hqSnapshotSource?.snapshotJpeg(EVENT_THUMB_HQ_MAX_WIDTH, EVENT_THUMB_HQ_QUALITY);
930
- if (hqJpeg) {
931
- this.eventManager.publishEventThumbnail(hqJpeg);
932
- return;
933
- }
934
- const handle = __addDisposableResource(env_1, await this.frameSource.fetchSnapshotFrame(), true);
935
- if (!handle)
936
- return;
937
- // Large frames (plugin-native snapshots) can afford the HQ thumbnail width.
938
- const maxWidth = handle.frame.width >= EVENT_THUMB_HQ_MAX_WIDTH * 2 ? EVENT_THUMB_HQ_MAX_WIDTH : EVENT_THUMB_MAX_WIDTH;
939
- const jpeg = await this.frameScaler.frameToJPEG(handle.frame, maxWidth);
940
- if (!jpeg)
941
- return;
942
- this.eventManager.publishEventThumbnail(jpeg);
943
- }
944
- catch (e_1) {
945
- env_1.error = e_1;
946
- env_1.hasError = true;
947
- }
948
- finally {
949
- const result_1 = __disposeResources(env_1);
950
- if (result_1)
951
- await result_1;
952
- }
953
- }
954
- catch (e) {
955
- this.logger.debug('event-thumb snapshot failed:', e);
956
- }
957
- })();
958
- }
959
- // Fallback: async snapshot replacement of a substream event thumbnail —
960
- // only reached when the HQ source couldn't deliver a frame inline at event
961
- // start (still connecting, buffer not warm yet).
962
- upgradeEventThumbnailAsync() {
963
- const source = this.hqSnapshotSource;
964
- if (!source?.isRunning || !source.hasBuffer || this.hqUpgradeInflight)
965
- return;
966
- this.hqUpgradeInflight = true;
967
- void (async () => {
968
- try {
969
- const jpeg = await source.snapshotJpeg(EVENT_THUMB_HQ_MAX_WIDTH, EVENT_THUMB_HQ_QUALITY);
970
- if (jpeg && this.eventManager.hasActiveEvent()) {
971
- this.eventManager.publishEventThumbnail(jpeg);
972
- }
973
- }
974
- catch (error) {
975
- this.logger.debug('Snapshot event thumbnail upgrade failed:', error);
976
- }
977
- finally {
978
- this.hqUpgradeInflight = false;
979
- }
980
- })();
981
- }
982
- // Recent snapshot frame + its scaler (own hardware context); caller disposes
983
- // the frame. With a reference size the aspect ratios must match — normalized
984
- // boxes only transfer between streams showing the same sensor area.
985
- async acquireHqFrame(reference) {
986
- const source = this.hqSnapshotSource;
987
- if (!source?.isRunning || !source.hasBuffer)
988
- return null;
989
- const scaler = source.scaler;
990
- if (!scaler)
991
- return null;
992
- const frame = await source.getFrame(HQ_FRAME_MAX_AGE_MS);
993
- if (!frame)
994
- return null;
995
- if (reference) {
996
- const refAspect = reference.width / reference.height;
997
- const hqAspect = frame.width / frame.height;
998
- if (Math.abs(refAspect - hqAspect) / refAspect > 0.02) {
999
- if (!this.hqAspectWarned) {
1000
- this.hqAspectWarned = true;
1001
- this.logger.warn(`[hq-thumb] aspect mismatch (detection ${reference.width}x${reference.height} vs snapshot ${frame.width}x${frame.height}) — detection crops stay on the substream`);
1002
- }
1003
- frame[Symbol.dispose]?.();
1004
- return null;
1005
- }
1006
- }
1007
- return { frame, scaler };
1008
- }
1009
- resolveHqSourceUrl(sources) {
1010
- if (!sources?.length)
1011
- return undefined;
1012
- const order = ['high-resolution', 'mid-resolution', 'low-resolution'];
1013
- const byRole = new Map(sources.map((s) => [s.role, s]));
1014
- // Detection consumes the LOWEST available role (worker: lowRes ?? midRes
1015
- // ?? streamSource) — the HQ companion is only worth a session when a
1016
- // strictly higher-resolution role exists.
1017
- const detectionRole = [...order].reverse().find((role) => byRole.has(role));
1018
- const hqRole = order.find((role) => byRole.has(role));
1019
- if (!detectionRole || !hqRole || hqRole === detectionRole)
1020
- return undefined;
1021
- return byRole.get(hqRole).url;
1022
- }
1023
- syncHqSnapshotSource() {
1024
- if (!this.hqSnapshotSource)
1025
- return;
1026
- const wanted = this.config.frameWorkerSettings.hqSnapshots === true;
1027
- if (wanted && !this.hqSnapshotSource.isRunning) {
1028
- this.hqSnapshotSource.start();
1029
- }
1030
- else if (!wanted && this.hqSnapshotSource.isRunning) {
1031
- this.hqSnapshotSource.stop();
1032
- }
1033
- }
1034
- calculateDBFS(data, format) {
1035
- let sumSquares = 0;
1036
- let sampleCount = 0;
1037
- if (format === 'float32') {
1038
- const floatView = new Float32Array(data.buffer, data.byteOffset, data.byteLength / 4);
1039
- sampleCount = floatView.length;
1040
- for (let i = 0; i < sampleCount; i++)
1041
- sumSquares += floatView[i] * floatView[i];
1042
- }
1043
- else {
1044
- // PCM16: normalize [-32768, 32767] → [-1.0, 1.0]
1045
- const int16View = new Int16Array(data.buffer, data.byteOffset, data.byteLength / 2);
1046
- sampleCount = int16View.length;
1047
- for (let i = 0; i < sampleCount; i++) {
1048
- const normalized = int16View[i] / 32768;
1049
- sumSquares += normalized * normalized;
1050
- }
1051
- }
1052
- if (sampleCount === 0)
1053
- return -100;
1054
- const rms = Math.sqrt(sumSquares / sampleCount);
1055
- return rms > 0 ? 20 * Math.log10(Math.max(rms, 1e-10)) : -100;
1056
- }
1057
- async runAudioDetectionLoop() {
1058
- const audioPlugin = this.singlePlugins.get(SensorType.Audio);
1059
- if (!audioPlugin?.requiresFrames)
1060
- return;
1061
- const audioSpec = this.isAudioModelSpec(audioPlugin.modelSpec) ? audioPlugin.modelSpec : undefined;
1062
- const sampleRate = audioSpec?.input.sampleRate ?? 16000;
1063
- const channels = audioSpec?.input.channels ?? 1;
1064
- const format = audioSpec?.input.format ?? 'float32';
1065
- const samplesPerFrame = audioSpec?.input.samplesPerFrame;
1066
- const audioConfig = {
1067
- streamUrl: this.config.audioStreamUrl,
1068
- sampleRate,
1069
- channels,
1070
- format,
1071
- samplesPerFrame,
1072
- };
1073
- while (this.audioLoopRunning) {
1074
- try {
1075
- this.audioSource = new AudioSource(audioConfig, this.logger);
1076
- await this.audioSource.start();
1077
- this.logger.debug('Audio stream connected, processing audio frames...');
1078
- this.audioReconnectCount = 0;
1079
- let frameCount = 0;
1080
- let lastAudioFrameId = -1;
1081
- while (this.audioLoopRunning) {
1082
- const snap = await this.audioSource.nextFrame(lastAudioFrameId);
1083
- if (!snap)
1084
- break; // source ended (stop or EOF)
1085
- lastAudioFrameId = snap.id;
1086
- const rawFrame = snap.frame;
1087
- frameCount++;
1088
- try {
1089
- const frameData = rawFrame.data;
1090
- if (!frameData?.[0])
1091
- continue;
1092
- const rawData = frameData[0];
1093
- const dBFS = this.calculateDBFS(rawData, format);
1094
- if (dBFS < this.audioMinDecibels)
1095
- continue;
1096
- const audioFrame = {
1097
- cameraId: this.config.cameraId,
1098
- data: rawData,
1099
- sampleRate,
1100
- channels,
1101
- format,
1102
- timestamp: Date.now(),
1103
- };
1104
- const result = await audioPlugin.proxy.detectAudio(audioFrame);
1105
- if (!result)
1106
- continue;
1107
- // Frame-based audio detection: cascade trigger when detected (Motion's
1108
- // dwell counterpart for audio is handled in ingestDetectionResult).
1109
- if (result.detected && this.cascadeEnabled) {
1110
- const audioTimeout = this.config.detectionSettings.audio.timeout;
1111
- this.cascade.triggerMomentary(audioTimeout);
1112
- }
1113
- // Funnel through the unified ingest path — same as external audio writes.
1114
- this.ingestDetectionResult(SensorType.Audio, audioPlugin.sensorId, {
1115
- detected: result.detected,
1116
- detections: result.detections ?? [],
1117
- ...(result.decibels !== undefined ? { decibels: result.decibels } : {}),
1118
- });
1119
- }
1120
- catch (error) {
1121
- this.logger.error('Audio detection error:', error);
1122
- }
1123
- finally {
1124
- try {
1125
- rawFrame[Symbol.dispose]?.();
1126
- }
1127
- catch {
1128
- // Best effort
1129
- }
1130
- }
1131
- }
1132
- const streamError = this.audioSource.lastError;
1133
- await this.audioSource.stop();
1134
- if (this.audioLoopRunning && streamError) {
1135
- this.audioReconnectCount = Math.min(this.audioReconnectCount + 1, this.maxReconnectCount);
1136
- const delay = this.audioReconnectCount >= this.maxReconnectCount ? this.maxReconnectDelay : this.normalReconnectDelay;
1137
- this.logger.warn(`Audio stream ended with read error, reconnecting in ${delay / 1000}s: ${streamError.message}`);
1138
- await new Promise((resolve) => setTimeout(resolve, delay));
1139
- }
1140
- else if (this.audioLoopRunning && frameCount === 0) {
1141
- this.logger.debug('Audio stream ended without frames, waiting before reconnect...');
1142
- await new Promise((resolve) => setTimeout(resolve, this.normalReconnectDelay));
1143
- }
1144
- }
1145
- catch (error) {
1146
- if (!this.audioLoopRunning)
1147
- break;
1148
- await this.audioSource?.stop();
1149
- this.audioReconnectCount = Math.min(this.audioReconnectCount + 1, this.maxReconnectCount);
1150
- const delay = this.audioReconnectCount >= this.maxReconnectCount ? this.maxReconnectDelay : this.normalReconnectDelay;
1151
- this.logger.warn(`Audio stream error, reconnecting in ${delay / 1000}s: ${error.message}`);
1152
- await new Promise((resolve) => setTimeout(resolve, delay));
1153
- }
1154
- }
1155
- await this.audioSource?.stop();
1156
- this.audioSource = undefined;
1157
- this.logger.debug('Audio detection loop ended');
1158
- }
1159
687
  async runDetectionLoop() {
1160
688
  while (this.loopRunning) {
1161
689
  try {
1162
690
  await this.frameSource.start();
1163
691
  this.frameScaler.updateHardwareContext(this.frameSource.hardwareContext);
1164
692
  this.logger.debug('Stream connected, processing frames...');
1165
- this.reconnectCount = 0;
693
+ this.videoBackoff.reset();
1166
694
  let frameCount = 0;
1167
695
  let lastFrameId = -1;
1168
696
  while (this.loopRunning) {
@@ -1179,7 +707,7 @@ let DetectionCoordinator = class DetectionCoordinator {
1179
707
  snap.frame[Symbol.dispose]?.();
1180
708
  }
1181
709
  catch {
1182
- // Best effort
710
+ // best-effort
1183
711
  }
1184
712
  }
1185
713
  }
@@ -1187,14 +715,13 @@ let DetectionCoordinator = class DetectionCoordinator {
1187
715
  await this.frameSource.stop();
1188
716
  this.frameScaler.clearCache();
1189
717
  if (this.loopRunning && streamError) {
1190
- this.reconnectCount = Math.min(this.reconnectCount + 1, this.maxReconnectCount);
1191
- const delay = this.reconnectCount >= this.maxReconnectCount ? this.maxReconnectDelay : this.normalReconnectDelay;
718
+ const delay = this.videoBackoff.nextDelayMs();
1192
719
  this.logger.warn(`Stream ended with read error, reconnecting in ${delay / 1000}s: ${streamError.message}`);
1193
- await new Promise((resolve) => setTimeout(resolve, delay));
720
+ await sleep(delay);
1194
721
  }
1195
722
  else if (this.loopRunning && frameCount === 0) {
1196
723
  this.logger.debug('Stream ended without frames, waiting before reconnect...');
1197
- await new Promise((resolve) => setTimeout(resolve, this.normalReconnectDelay));
724
+ await sleep(this.videoBackoff.idleDelayMs);
1198
725
  }
1199
726
  }
1200
727
  catch (error) {
@@ -1202,10 +729,9 @@ let DetectionCoordinator = class DetectionCoordinator {
1202
729
  break;
1203
730
  await this.frameSource.stop();
1204
731
  this.frameScaler.clearCache();
1205
- this.reconnectCount = Math.min(this.reconnectCount + 1, this.maxReconnectCount);
1206
- const delay = this.reconnectCount >= this.maxReconnectCount ? this.maxReconnectDelay : this.normalReconnectDelay;
732
+ const delay = this.videoBackoff.nextDelayMs();
1207
733
  this.logger.warn(`Stream error, reconnecting in ${delay / 1000}s: ${error.message}`);
1208
- await new Promise((resolve) => setTimeout(resolve, delay));
734
+ await sleep(delay);
1209
735
  }
1210
736
  }
1211
737
  await this.frameSource.stop();
@@ -1213,7 +739,7 @@ let DetectionCoordinator = class DetectionCoordinator {
1213
739
  this.logger.debug('Detection loop ended');
1214
740
  }
1215
741
  async processRawFrame(rawFrame) {
1216
- if (!this.loopRunning || !this.hasAnyPlugins())
742
+ if (!this.loopRunning || !this.plugins.hasAny())
1217
743
  return;
1218
744
  try {
1219
745
  await this.processDetection(rawFrame);
@@ -1227,21 +753,18 @@ let DetectionCoordinator = class DetectionCoordinator {
1227
753
  let motionDetected = false;
1228
754
  let objectDetections = [];
1229
755
  const results = { timestamp: t0 };
1230
- // Refresh ReID every frame while cascade is active so dead tracks never
1231
- // expire during the cascade window.
756
+ // keeps dead tracks from expiring during the cascade window
1232
757
  if (this.cascade.isActive) {
1233
758
  this.pipeline.refreshReid();
1234
759
  }
1235
- // PTZ suppression: motion is skipped entirely (frame-diff is garbage while
1236
- // the camera repositions); object keeps running Norfair gets the pose
1237
- // delta so track ids survive pans, and the autotracker gates motor
1238
- // commands on its own isPtzMoving state.
760
+ // while the PTZ repositions: motion and object keep running so the
761
+ // background model and track ids survive the pan, but nothing below
762
+ // reaches dwell/events until the settle window ends
1239
763
  const ptzSuppressed = this.ptzAutotracker.suppressionActive;
1240
- // 1. Motion every frame regardless of cascade state: keeps UI bboxes
1241
- // fresh AND re-triggers the cascade so sustained motion keeps it alive.
1242
- // The cascade gates OBJECT detection, not motion.
1243
- const motionPlugin = this.singlePlugins.get(SensorType.Motion);
1244
- if (motionPlugin?.requiresFrames && !ptzSuppressed) {
764
+ // motion runs every frame and re-arms the cascade; the cascade gates
765
+ // object detection, not motion
766
+ const motionPlugin = this.plugins.get(SensorType.Motion);
767
+ if (motionPlugin?.requiresFrames) {
1245
768
  const motionFrame = await this.scaleForMotion(rawFrame);
1246
769
  if (!this.loopRunning)
1247
770
  return;
@@ -1250,13 +773,18 @@ let DetectionCoordinator = class DetectionCoordinator {
1250
773
  const result = await motionPlugin.proxy.detectMotion(motionFrame);
1251
774
  if (!this.loopRunning)
1252
775
  return;
1253
- if (result.detections.length > 0) {
776
+ if (ptzSuppressed) {
777
+ // fed for the background model only, results discarded: frame-diff
778
+ // is garbage during ego-motion, but skipping frames would leave a
779
+ // stale background that lights up the whole scene after the move
780
+ }
781
+ else if (result.detections.length > 0) {
1254
782
  const filtered = this.pipeline.runMergeAndZoneFilter(result.detections);
1255
783
  motionDetected = filtered.length > 0;
1256
784
  results.motion = { ...result, detections: filtered };
1257
785
  }
1258
786
  else {
1259
- // Plugin reports no motion this frame — record empty so UI clears stale bboxes
787
+ // record empty so the UI clears stale bboxes
1260
788
  results.motion = { detected: false, detections: [] };
1261
789
  }
1262
790
  }
@@ -1268,22 +796,20 @@ let DetectionCoordinator = class DetectionCoordinator {
1268
796
  }
1269
797
  }
1270
798
  else if (this.cascade.isActive) {
1271
- // No frame-based motion plugin cascade is the only motion-equivalent signal
799
+ // no frame-based motion plugin, the cascade is the only motion-equivalent signal
1272
800
  motionDetected = true;
1273
801
  results.cascadeTriggered = true;
1274
802
  }
1275
- // `detected` is re-derived from post-zone-filter detections the plugin's
1276
- // own flag is ignored on purpose, zone filtering wins.
803
+ // detected is re-derived after zone filtering, the plugin's own flag is ignored
1277
804
  if (results.motion && motionPlugin) {
1278
805
  this.ingestDetectionResult(SensorType.Motion, motionPlugin.sensorId, {
1279
806
  detected: motionDetected,
1280
807
  detections: results.motion.detections,
1281
808
  });
1282
809
  }
1283
- // 2. Object gated by the cascade (wake sources: motion, audio, sensor
1284
- // trigger), always-on when cascade is disabled, and kept running during
1285
- // PTZ suppression so the tracker holds its lock across pans.
1286
- const objectPlugin = this.singlePlugins.get(SensorType.Object);
810
+ // object is cascade-gated, but keeps running during PTZ suppression so
811
+ // the tracker holds its lock across pans
812
+ const objectPlugin = this.plugins.get(SensorType.Object);
1287
813
  const shouldDetectObjects = objectPlugin?.requiresFrames === true && (ptzSuppressed || !this.cascadeEnabled || this.cascade.isActive);
1288
814
  if (shouldDetectObjects) {
1289
815
  const objectFrame = await this.scaleForObject(rawFrame);
@@ -1291,17 +817,24 @@ let DetectionCoordinator = class DetectionCoordinator {
1291
817
  return;
1292
818
  if (objectFrame) {
1293
819
  try {
1294
- const result = await withTimeout(objectPlugin.proxy.detectObjects(objectFrame), DETECT_TIMEOUT_MS, 'Object');
820
+ const result = await PromiseTimeout(objectPlugin.proxy.detectObjects(objectFrame), DETECT_TIMEOUT_MS, undefined, `Object detection timed out after ${DETECT_TIMEOUT_MS}ms`);
1295
821
  if (!this.loopRunning)
1296
822
  return;
1297
- // Full pipeline even on empty frames so Norfair advances Kalman
1298
- // state; the PTZ pose delta keeps predictions world-stable across
1299
- // pans (track ids survive instead of LOST → REACQUIRE).
823
+ // run the pipeline even on empty frames so Norfair advances its
824
+ // Kalman state; the pose delta keeps predictions stable across pans
1300
825
  const poseDelta = this.ptzAutotracker.consumePoseDelta();
1301
826
  const pipelineResult = this.pipeline.process(result.detections, objectFrame, poseDelta);
1302
- // Extrapolated (Kalman-only) tracks smooth single-frame detector
1303
- // misses in the UI, but are dropped once they drift off-frame —
1304
- // otherwise ghost bboxes float outside the stream.
827
+ // track id churn is invisible in the event log without this
828
+ if (pipelineResult.created.length > 0 || pipelineResult.removed.length > 0) {
829
+ const born = pipelineResult.created.map((id) => {
830
+ const t = pipelineResult.tracked.find((d) => d.trackId === id);
831
+ return `${t?.label ?? 'unknown'}#${id}`;
832
+ });
833
+ const died = pipelineResult.removed.map((id) => `#${id}${this.stationary.isAnchored(id) ? ' (anchored!)' : ''}`);
834
+ this.logger.trace(`[tracker] ${born.length ? `born: ${born.join(', ')}` : ''}${born.length && died.length ? ' — ' : ''}${died.length ? `died: ${died.join(', ')}` : ''}`);
835
+ }
836
+ // extrapolated (Kalman-only) tracks smooth single-frame misses in
837
+ // the UI, but must not float off-frame as ghost bboxes
1305
838
  const visibleTracks = pipelineResult.tracked.filter((t) => {
1306
839
  if (!t.trackLost)
1307
840
  return true;
@@ -1313,9 +846,8 @@ let DetectionCoordinator = class DetectionCoordinator {
1313
846
  results.object = { ...result, detections: visibleTracks };
1314
847
  if (pipelineResult.crossings.length > 0)
1315
848
  results.lineCrossings = pipelineResult.crossings;
1316
- // Feed the autotracker ALL tracked detections incl. extrapolated —
1317
- // a single missed detector frame must not flip it into LOST/REACQUIRE
1318
- // churn. It gates motor decisions on its own isPtzMoving flag.
849
+ // incl. extrapolated tracks, otherwise a single missed detector
850
+ // frame flips the autotracker into LOST/REACQUIRE churn
1319
851
  this.ptzAutotracker.handleObjectDetections(pipelineResult.tracked);
1320
852
  }
1321
853
  catch (error) {
@@ -1325,14 +857,25 @@ let DetectionCoordinator = class DetectionCoordinator {
1325
857
  }
1326
858
  }
1327
859
  }
860
+ // tracker and autotracker are fed, everything event-facing stays quiet
861
+ if (ptzSuppressed) {
862
+ // object boxes stay live in the UI through the move, bbox publish only
863
+ if (objectPlugin && results.object) {
864
+ this.writeSensorProperties(objectPlugin.sensorId, { detections: results.object.detections });
865
+ }
866
+ // keep an already-running object dwell alive so a chase doesn't end its
867
+ // own event, but never activate it from suppressed frames
868
+ if (objectPlugin && objectDetections.length > 0 && this.dwell.isActive(objectPlugin.sensorId)) {
869
+ this.dwell.refresh(objectPlugin.sensorId, OBJECT_DWELL_SECONDS);
870
+ }
871
+ return;
872
+ }
1328
873
  if (results.lineCrossings && results.lineCrossings.length > 0) {
1329
874
  for (const c of results.lineCrossings) {
1330
- this.logger.debug(`Line crossing: ${c.label}#${c.trackId} crossed "${c.lineName}" dir=${c.direction} conf=${c.confidence.toFixed(2)}`);
875
+ this.logger.trace(`Line crossing: ${c.label}#${c.trackId} crossed "${c.lineName}" dir=${c.direction} conf=${c.confidence.toFixed(2)}`);
1331
876
  }
1332
- // Buffer line crossings so the EventManager snapshot picks them up
1333
877
  this.currentDetectionState.lineCrossings = results.lineCrossings;
1334
878
  }
1335
- // Same rule as motion: detected is re-derived from post-pipeline detections.
1336
879
  if (results.object && objectPlugin) {
1337
880
  this.ingestDetectionResult(SensorType.Object, objectPlugin.sensorId, {
1338
881
  detected: objectDetections.length > 0,
@@ -1341,227 +884,67 @@ let DetectionCoordinator = class DetectionCoordinator {
1341
884
  }
1342
885
  if (!this.loopRunning)
1343
886
  return;
1344
- // 3. Secondary detectors + per-detection thumbnails. Result-collection
1345
- // only — the shared ingest below keeps loop tick and external-RPC path
1346
- // on the same helper.
1347
887
  if (objectDetections.length > 0) {
1348
888
  await this.runSecondariesAndThumbnails(rawFrame, objectDetections, results);
1349
889
  }
1350
890
  if (!this.loopRunning)
1351
891
  return;
1352
- // 4. Unified ingest.
1353
892
  this.ingestResultsForAllSecondaries(results);
1354
- // 5. Single flush per frame, AFTER all detectors + thumbnails — so
1355
- // segment-start messages already carry thumbnails and secondary data.
893
+ this.clearStaleSecondaryBboxes();
894
+ // one flush per frame, after detectors and thumbnails, so segment-start
895
+ // messages already carry them
1356
896
  if (!this.loopRunning)
1357
897
  return;
1358
898
  const snapshot = this.buildSnapshot();
1359
899
  if (results.thumbnails && results.thumbnails.length > 0) {
1360
900
  snapshot.thumbnails = results.thumbnails;
1361
901
  }
1362
- // Event-level thumbnail generated lazily when the EventManager still
1363
- // needs one OR this frame's triggers will start an event (pre-encode so
1364
- // the 'start' message carries it inline). Attaching is harmless either
1365
- // way; EventManager only consumes it while needsEventThumbnail is true.
1366
- // Full frame prefers the snapshot source (no aspect constraint needed).
902
+ // pre-encode the event thumbnail when this frame starts an event, so the
903
+ // start message carries it inline; attaching is harmless otherwise
1367
904
  const wantsEventThumb = this.eventManager.needsThumbnail() || this.snapshotWillStartEvent(snapshot);
1368
905
  let hqThumbAttached = false;
1369
906
  if (wantsEventThumb) {
1370
907
  try {
1371
- const hq = await this.acquireHqFrame();
1372
- try {
1373
- if (hq) {
1374
- const jpeg = await hq.scaler.frameToJPEG(hq.frame, EVENT_THUMB_HQ_MAX_WIDTH, EVENT_THUMB_HQ_QUALITY);
1375
- if (jpeg) {
1376
- snapshot.eventThumbnail = jpeg;
1377
- hqThumbAttached = true;
1378
- }
1379
- }
1380
- if (!snapshot.eventThumbnail) {
1381
- const jpeg = await this.frameScaler.frameToJPEG(rawFrame, EVENT_THUMB_MAX_WIDTH);
1382
- if (jpeg)
1383
- snapshot.eventThumbnail = jpeg;
1384
- }
1385
- }
1386
- finally {
1387
- hq?.frame[Symbol.dispose]?.();
1388
- }
908
+ const thumb = await this.thumbnailer.captureEventThumbnail(rawFrame);
909
+ if (thumb.jpeg)
910
+ snapshot.eventThumbnail = thumb.jpeg;
911
+ hqThumbAttached = thumb.fromHq;
1389
912
  }
1390
913
  catch (error) {
1391
914
  this.logger.error('Event thumbnail generation error:', error);
1392
915
  }
1393
916
  }
1394
917
  this.eventManager.processResults(snapshot);
1395
- // Inline HQ succeeded nothing to upgrade; otherwise the async path
1396
- // covers the window where the HQ source wasn't ready at event start.
918
+ // covers the window where the HQ source wasn't ready at event start
1397
919
  if (wantsEventThumb && !hqThumbAttached) {
1398
- this.upgradeEventThumbnailAsync();
920
+ this.thumbnailer.upgradeEventThumbnailAsync();
1399
921
  }
1400
922
  }
1401
- snapshotWillStartEvent(snapshot) {
1402
- if (this.eventManager.hasActiveEvent())
1403
- return false;
1404
- if (snapshot.motion?.detected)
1405
- return true;
1406
- if (snapshot.audio?.detected)
1407
- return true;
1408
- if (snapshot.cascadeTriggered)
1409
- return true;
1410
- if ((snapshot.sensorTriggers?.length ?? 0) > 0)
1411
- return true;
1412
- if ((snapshot.lineCrossings?.length ?? 0) > 0)
1413
- return true;
1414
- return false;
1415
- }
1416
- async resolveFaceIdentities(faces) {
1417
- const withEmbeddings = faces.filter((f) => f.embedding?.length);
1418
- if (!withEmbeddings.length)
1419
- return;
1420
- const facePlugin = this.singlePlugins.get(SensorType.Face);
1421
- const embeddingModel = facePlugin?.modelSpec?.embeddingModel;
1422
- if (!embeddingModel)
1423
- return;
1424
- const nvr = await this.getNvrProxy();
1425
- if (!nvr)
923
+ async runSecondariesAndThumbnails(rawFrame, objectDetections, results) {
924
+ objectDetections = this.stationary.excludeSealed(objectDetections);
925
+ if (objectDetections.length === 0)
1426
926
  return;
927
+ await this.secondaries.detect(rawFrame, objectDetections, results);
1427
928
  try {
1428
- const embeddings = withEmbeddings.map((f) => f.embedding);
1429
- const matches = await nvr.matchFaces(embeddings, embeddingModel);
1430
- for (let i = 0; i < withEmbeddings.length; i++) {
1431
- if (matches[i]) {
1432
- withEmbeddings[i].identity = matches[i].identity;
1433
- }
1434
- }
1435
- }
1436
- catch (error) {
1437
- if (!this.loopRunning || isNoRespondersError(error))
1438
- return;
1439
- this.logger.error('Could not resolve face identities:', error);
1440
- }
1441
- }
1442
- async scaleForMotion(rawFrame) {
1443
- const motionPlugin = this.singlePlugins.get(SensorType.Motion);
1444
- if (!motionPlugin?.requiresFrames)
1445
- return undefined;
1446
- const maxWidth = MOTION_WIDTH_MAP[this.motionResolution];
1447
- const scaled = await this.frameScaler.scaleProportional(rawFrame, maxWidth, 'gray');
1448
- return scaled ? this.frameScaler.toVideoFrameData(scaled) : undefined;
1449
- }
1450
- async scaleForObject(rawFrame) {
1451
- const objectPlugin = this.singlePlugins.get(SensorType.Object);
1452
- if (!objectPlugin?.requiresFrames)
1453
- return undefined;
1454
- const inputSpec = objectPlugin.modelSpec?.input;
1455
- if (!this.isVideoInputSpec(inputSpec))
1456
- return undefined;
1457
- const scaled = await this.frameScaler.scaleToSpec(rawFrame, inputSpec);
1458
- return scaled ? this.frameScaler.toVideoFrameData(scaled) : undefined;
1459
- }
1460
- async prepareSecondaryRegions(rawFrame, objectDetections) {
1461
- const result = new Map();
1462
- const consumers = [];
1463
- const facePlugin = this.singlePlugins.get(SensorType.Face);
1464
- if (facePlugin && this.hasSecondaryModelSpec(facePlugin.modelSpec) && this.isVideoInputSpec(facePlugin.modelSpec.input)) {
1465
- consumers.push({ key: 'face', triggerLabels: facePlugin.modelSpec.triggerLabels, input: facePlugin.modelSpec.input });
1466
- }
1467
- const lpdPlugin = this.singlePlugins.get(SensorType.LicensePlate);
1468
- if (lpdPlugin && this.hasSecondaryModelSpec(lpdPlugin.modelSpec) && this.isVideoInputSpec(lpdPlugin.modelSpec.input)) {
1469
- consumers.push({ key: 'lpd', triggerLabels: lpdPlugin.modelSpec.triggerLabels, input: lpdPlugin.modelSpec.input });
1470
- }
1471
- const clipPlugin = this.singlePlugins.get(SensorType.Clip);
1472
- if (clipPlugin && this.hasSecondaryModelSpec(clipPlugin.modelSpec) && this.isVideoInputSpec(clipPlugin.modelSpec.input)) {
1473
- consumers.push({ key: 'clip', triggerLabels: clipPlugin.modelSpec.triggerLabels, input: clipPlugin.modelSpec.input });
1474
- }
1475
- const classifierPlugins = this.multiPlugins.get(SensorType.Classifier);
1476
- if (classifierPlugins) {
1477
- for (const [pluginId, classifierPlugin] of classifierPlugins) {
1478
- if (this.hasSecondaryModelSpec(classifierPlugin.modelSpec) && this.isVideoInputSpec(classifierPlugin.modelSpec.input)) {
1479
- consumers.push({ key: `classifier:${pluginId}`, triggerLabels: classifierPlugin.modelSpec.triggerLabels, input: classifierPlugin.modelSpec.input });
1480
- }
1481
- }
1482
- }
1483
- if (consumers.length === 0)
1484
- return result;
1485
- for (const detection of objectDetections) {
1486
- const detLabel = detection.label.toLowerCase();
1487
- const targets = [];
1488
- for (const consumer of consumers) {
1489
- if (consumer.triggerLabels.length === 0 || consumer.triggerLabels.some((l) => l.toLowerCase() === detLabel)) {
1490
- targets.push({ key: consumer.key, width: consumer.input.width, height: consumer.input.height, format: consumer.input.format });
1491
- }
1492
- }
1493
- if (targets.length === 0)
1494
- continue;
1495
- // Consumers with identical dimensions share one scaled region
1496
- const uniqueTargets = [];
1497
- const dupeMap = new Map(); // sizeKey → [consumerKey, ...]
1498
- for (const t of targets) {
1499
- const sizeKey = `${t.width}x${t.height}_${t.format}`;
1500
- const existing = dupeMap.get(sizeKey);
1501
- if (existing) {
1502
- existing.push(t.key);
1503
- }
1504
- else {
1505
- dupeMap.set(sizeKey, [t.key]);
1506
- uniqueTargets.push(t);
1507
- }
929
+ // detectors ran on the substream frame, thumbnails prefer the HQ frame
930
+ const hq = await this.thumbnailer.acquireHqFrame({ width: rawFrame.width, height: rawFrame.height });
931
+ try {
932
+ results.thumbnails = await this.secondaries.generateThumbnails(hq?.frame ?? rawFrame, hq?.scaler ?? this.frameScaler, hq !== null, objectDetections, results);
1508
933
  }
1509
- const regions = await this.frameScaler.cropAndScaleMulti(rawFrame, detection, uniqueTargets);
1510
- for (const [_sizeKey, consumerKeys] of dupeMap) {
1511
- const primaryKey = consumerKeys[0];
1512
- const region = regions.get(primaryKey);
1513
- if (!region)
1514
- continue;
1515
- for (const key of consumerKeys) {
1516
- const list = result.get(key);
1517
- if (list) {
1518
- list.push(region);
1519
- }
1520
- else {
1521
- result.set(key, [region]);
1522
- }
1523
- }
934
+ finally {
935
+ hq?.frame[Symbol.dispose]?.();
1524
936
  }
1525
937
  }
1526
- return result;
1527
- }
1528
- async runSecondaryDetection(type, results, fn) {
1529
- let detections = undefined;
1530
- try {
1531
- detections = await fn();
1532
- }
1533
938
  catch (error) {
1534
- if (!this.loopRunning || isNoRespondersError(error))
1535
- return;
1536
- const logType = type === SensorType.Face ? 'Face' : type === SensorType.LicensePlate ? 'License plate' : type === SensorType.Clip ? 'CLIP' : 'Classifier';
1537
- this.logger.error(`${logType} detection error:`, error);
1538
- }
1539
- if (!detections)
1540
- return;
1541
- if (type === SensorType.Face) {
1542
- results.face = detections;
1543
- await this.resolveFaceIdentities(results.face.detections);
1544
- }
1545
- else if (type === SensorType.LicensePlate) {
1546
- results.licensePlate = detections;
1547
- }
1548
- else if (type === SensorType.Clip) {
1549
- results.clip = detections;
1550
- const clipPlugin = this.singlePlugins.get(SensorType.Clip);
1551
- results.clipEmbeddingModel = clipPlugin?.modelSpec?.embeddingModel;
1552
- }
1553
- else if (type === SensorType.Classifier) {
1554
- results.classifiers ??= {};
1555
- for (const { pluginId, result } of detections) {
1556
- results.classifiers[pluginId] = result;
1557
- }
1558
- if (Object.keys(results.classifiers).length === 0)
1559
- delete results.classifiers;
939
+ this.logger.error('Thumbnail generation error:', error);
1560
940
  }
1561
941
  }
1562
942
  ingestResultsForAllSecondaries(results) {
1563
943
  if (results.face) {
1564
- const facePlugin = this.singlePlugins.get(SensorType.Face);
944
+ if (results.faceEmbeddingModel) {
945
+ this.currentDetectionState.faceEmbeddingModel = results.faceEmbeddingModel;
946
+ }
947
+ const facePlugin = this.plugins.get(SensorType.Face);
1565
948
  if (facePlugin) {
1566
949
  this.ingestDetectionResult(SensorType.Face, facePlugin.sensorId, {
1567
950
  detected: results.face.detected,
@@ -1570,7 +953,7 @@ let DetectionCoordinator = class DetectionCoordinator {
1570
953
  }
1571
954
  }
1572
955
  if (results.licensePlate) {
1573
- const lpdPlugin = this.singlePlugins.get(SensorType.LicensePlate);
956
+ const lpdPlugin = this.plugins.get(SensorType.LicensePlate);
1574
957
  if (lpdPlugin) {
1575
958
  this.ingestDetectionResult(SensorType.LicensePlate, lpdPlugin.sensorId, {
1576
959
  detected: results.licensePlate.detected,
@@ -1579,345 +962,54 @@ let DetectionCoordinator = class DetectionCoordinator {
1579
962
  }
1580
963
  }
1581
964
  if (results.classifiers) {
1582
- const classifierPlugins = this.multiPlugins.get(SensorType.Classifier);
1583
- if (classifierPlugins) {
1584
- for (const [pluginId, classifierResult] of Object.entries(results.classifiers)) {
1585
- const plugin = classifierPlugins.get(pluginId);
1586
- if (plugin) {
1587
- this.ingestDetectionResult(SensorType.Classifier, plugin.sensorId, {
1588
- detected: classifierResult.detected,
1589
- detections: classifierResult.detections,
1590
- }, pluginId);
1591
- }
965
+ for (const [pluginId, classifierResult] of Object.entries(results.classifiers)) {
966
+ const plugin = this.plugins.getAll(SensorType.Classifier).find((p) => p.pluginId === pluginId);
967
+ if (plugin) {
968
+ this.ingestDetectionResult(SensorType.Classifier, plugin.sensorId, {
969
+ detected: classifierResult.detected,
970
+ detections: classifierResult.detections,
971
+ }, pluginId);
1592
972
  }
1593
973
  }
1594
974
  }
1595
975
  if (results.clip) {
1596
- // Clip has no sensor properties — embeddings flow through buffer only.
976
+ // clip has no sensor, embeddings flow through the buffer only
1597
977
  this.currentDetectionState.clip = results.clip;
1598
978
  this.currentDetectionState.clipEmbeddingModel = results.clipEmbeddingModel;
1599
979
  }
1600
980
  }
1601
- async runSecondariesAndThumbnails(rawFrame, objectDetections, results) {
1602
- if (objectDetections.length === 0)
1603
- return;
1604
- const regionMap = await this.prepareSecondaryRegions(rawFrame, objectDetections);
1605
- await Promise.allSettled([
1606
- this.runSecondaryDetection(SensorType.Face, results, () => this.runFaceDetection(regionMap.get('face') ?? [])),
1607
- this.runSecondaryDetection(SensorType.LicensePlate, results, () => this.runLicensePlateDetection(regionMap.get('lpd') ?? [])),
1608
- this.runSecondaryDetection(SensorType.Classifier, results, () => this.runClassifierDetections(regionMap)),
1609
- this.runSecondaryDetection(SensorType.Clip, results, () => this.runClipDetection(regionMap.get('clip') ?? [])),
1610
- ]);
1611
- if (results.face?.detections.length) {
1612
- const facePlugin = this.singlePlugins.get(SensorType.Face);
1613
- results.faceEmbeddingModel = facePlugin?.modelSpec?.embeddingModel;
1614
- this.currentDetectionState.faceEmbeddingModel = results.faceEmbeddingModel;
1615
- }
1616
- try {
1617
- // Detectors consumed the substream frame above; images destined for the
1618
- // event (chips, scene) prefer the snapshot frame when available.
1619
- const hq = await this.acquireHqFrame({ width: rawFrame.width, height: rawFrame.height });
1620
- try {
1621
- results.thumbnails = await this.generateThumbnails(hq?.frame ?? rawFrame, hq?.scaler ?? this.frameScaler, hq !== null, objectDetections, results);
1622
- }
1623
- finally {
1624
- hq?.frame[Symbol.dispose]?.();
1625
- }
1626
- }
1627
- catch (error) {
1628
- this.logger.error('Thumbnail generation error:', error);
1629
- }
1630
- }
1631
- async runSecondariesFullFrame(rawFrame, results) {
1632
- const regionMap = await this.prepareFullFrameRegions(rawFrame);
1633
- if (regionMap.size > 0) {
1634
- await Promise.allSettled([
1635
- this.runSecondaryDetection(SensorType.Face, results, () => this.runFaceDetection(regionMap.get('face') ?? [])),
1636
- this.runSecondaryDetection(SensorType.LicensePlate, results, () => this.runLicensePlateDetection(regionMap.get('lpd') ?? [])),
1637
- this.runSecondaryDetection(SensorType.Classifier, results, () => this.runClassifierDetections(regionMap)),
1638
- this.runSecondaryDetection(SensorType.Clip, results, () => this.runClipDetection(regionMap.get('clip') ?? [])),
1639
- ]);
1640
- if (results.face?.detections.length) {
1641
- const facePlugin = this.singlePlugins.get(SensorType.Face);
1642
- results.faceEmbeddingModel = facePlugin?.modelSpec?.embeddingModel;
1643
- this.currentDetectionState.faceEmbeddingModel = results.faceEmbeddingModel;
1644
- }
1645
- }
1646
- try {
1647
- return await this.frameScaler.frameToJPEG(rawFrame, EVENT_THUMB_MAX_WIDTH);
1648
- }
1649
- catch (error) {
1650
- this.logger.debug('Full-frame scene thumbnail failed:', error);
1651
- return null;
1652
- }
1653
- }
1654
- async prepareFullFrameRegions(rawFrame) {
1655
- const result = new Map();
1656
- const consumers = [];
1657
- const facePlugin = this.singlePlugins.get(SensorType.Face);
1658
- if (facePlugin?.requiresFrames && this.hasSecondaryModelSpec(facePlugin.modelSpec) && this.isVideoInputSpec(facePlugin.modelSpec.input)) {
1659
- consumers.push({ key: 'face', triggerLabels: facePlugin.modelSpec.triggerLabels, input: facePlugin.modelSpec.input });
1660
- }
1661
- const lpdPlugin = this.singlePlugins.get(SensorType.LicensePlate);
1662
- if (lpdPlugin?.requiresFrames && this.hasSecondaryModelSpec(lpdPlugin.modelSpec) && this.isVideoInputSpec(lpdPlugin.modelSpec.input)) {
1663
- consumers.push({ key: 'lpd', triggerLabels: lpdPlugin.modelSpec.triggerLabels, input: lpdPlugin.modelSpec.input });
1664
- }
1665
- const clipPlugin = this.singlePlugins.get(SensorType.Clip);
1666
- if (clipPlugin?.requiresFrames && this.hasSecondaryModelSpec(clipPlugin.modelSpec) && this.isVideoInputSpec(clipPlugin.modelSpec.input)) {
1667
- consumers.push({ key: 'clip', triggerLabels: clipPlugin.modelSpec.triggerLabels, input: clipPlugin.modelSpec.input });
1668
- }
1669
- const classifierPlugins = this.multiPlugins.get(SensorType.Classifier);
1670
- if (classifierPlugins) {
1671
- for (const [pluginId, plugin] of classifierPlugins) {
1672
- if (plugin.requiresFrames && this.hasSecondaryModelSpec(plugin.modelSpec) && this.isVideoInputSpec(plugin.modelSpec.input)) {
1673
- consumers.push({ key: `classifier:${pluginId}`, triggerLabels: plugin.modelSpec.triggerLabels, input: plugin.modelSpec.input });
1674
- }
1675
- }
1676
- }
1677
- if (consumers.length === 0)
1678
- return result;
1679
- const frameWidth = rawFrame.width;
1680
- const frameHeight = rawFrame.height;
1681
- const fullFrameDetection = {
1682
- label: 'person',
1683
- confidence: 1.0,
1684
- box: { x: 0, y: 0, width: 1, height: 1 },
1685
- };
1686
- for (const consumer of consumers) {
1687
- const scaled = await this.frameScaler.scaleToSpec(rawFrame, consumer.input);
1688
- if (!scaled)
1689
- continue;
1690
- const region = {
1691
- frame: this.frameScaler.toVideoFrameData(scaled, `fullframe:${consumer.key}`),
1692
- detection: fullFrameDetection,
1693
- offset: { x: 0, y: 0 },
1694
- cropSize: { width: frameWidth, height: frameHeight },
1695
- originalSize: { width: frameWidth, height: frameHeight },
1696
- };
1697
- result.set(consumer.key, [region]);
1698
- }
1699
- return result;
1700
- }
1701
- prepareSecondaryFrames(croppedRegions) {
1702
- return croppedRegions.map((r, i) => ({ ...r.frame, id: String(i), label: r.detection.label }));
1703
- }
1704
- async runFaceDetection(croppedRegions) {
1705
- const facePlugin = this.singlePlugins.get(SensorType.Face);
1706
- if (!facePlugin || croppedRegions.length === 0)
1707
- return undefined;
1708
- const batchResults = await withTimeout(facePlugin.proxy.detectFaces(this.prepareSecondaryFrames(croppedRegions)), DETECT_TIMEOUT_MS, 'Face');
1709
- const allFaces = [];
1710
- for (let i = 0; i < batchResults.length; i++) {
1711
- const parentTrackId = 'trackId' in croppedRegions[i].detection ? croppedRegions[i].detection.trackId : undefined;
1712
- for (const face of batchResults[i].detections) {
1713
- const transformed = this.transformBoxToOriginal(face.box, croppedRegions[i]);
1714
- allFaces.push({ ...face, box: transformed, parentTrackId });
1715
- }
1716
- }
1717
- if (allFaces.length === 0)
1718
- return { detected: false, detections: [] };
1719
- // NMS only — zone filter is skipped because face crops come from
1720
- // object detections that already passed zone filtering in process().
1721
- const deduped = this.pipeline.runNms(allFaces);
1722
- return { detected: deduped.length > 0, detections: deduped };
981
+ snapshotWillStartEvent(snapshot) {
982
+ if (this.eventManager.hasActiveEvent())
983
+ return false;
984
+ if (snapshot.motion?.detected)
985
+ return true;
986
+ if (snapshot.audio?.detected)
987
+ return true;
988
+ if (snapshot.cascadeTriggered)
989
+ return true;
990
+ if ((snapshot.sensorTriggers?.length ?? 0) > 0)
991
+ return true;
992
+ if ((snapshot.lineCrossings?.length ?? 0) > 0)
993
+ return true;
994
+ return false;
1723
995
  }
1724
- async runLicensePlateDetection(croppedRegions) {
1725
- const lpdPlugin = this.singlePlugins.get(SensorType.LicensePlate);
1726
- if (!lpdPlugin || croppedRegions.length === 0)
996
+ async scaleForMotion(rawFrame) {
997
+ const motionPlugin = this.plugins.get(SensorType.Motion);
998
+ if (!motionPlugin?.requiresFrames)
1727
999
  return undefined;
1728
- const batchResults = await withTimeout(lpdPlugin.proxy.detectLicensePlates(this.prepareSecondaryFrames(croppedRegions)), DETECT_TIMEOUT_MS, 'License plate');
1729
- const allPlates = [];
1730
- for (let i = 0; i < batchResults.length; i++) {
1731
- const parentTrackId = 'trackId' in croppedRegions[i].detection ? croppedRegions[i].detection.trackId : undefined;
1732
- for (const plate of batchResults[i].detections) {
1733
- allPlates.push({ ...plate, box: this.transformBoxToOriginal(plate.box, croppedRegions[i]), parentTrackId });
1734
- }
1735
- }
1736
- if (allPlates.length === 0)
1737
- return { detected: false, detections: [] };
1738
- // NMS only — zone filter skipped (crops from already-filtered object boxes).
1739
- const deduped = this.pipeline.runNms(allPlates);
1740
- return { detected: deduped.length > 0, detections: deduped };
1741
- }
1742
- runClassifierDetections(regionMap) {
1743
- const classifierPlugins = this.multiPlugins.get(SensorType.Classifier);
1744
- if (!classifierPlugins || classifierPlugins.size === 0)
1745
- return [];
1746
- const promises = [];
1747
- for (const [pluginId, classifierPlugin] of classifierPlugins) {
1748
- const croppedRegions = regionMap.get(`classifier:${pluginId}`);
1749
- if (!croppedRegions || croppedRegions.length === 0)
1750
- continue;
1751
- const runClassifier = async () => {
1752
- const batchResults = await withTimeout(classifierPlugin.proxy.detectClassifications(this.prepareSecondaryFrames(croppedRegions)), DETECT_TIMEOUT_MS, 'Classifier');
1753
- const allDetections = [];
1754
- for (let i = 0; i < batchResults.length; i++) {
1755
- const parentTrackId = 'trackId' in croppedRegions[i].detection ? croppedRegions[i].detection.trackId : undefined;
1756
- for (const detection of batchResults[i].detections) {
1757
- allDetections.push({ ...detection, box: this.transformBoxToOriginal(detection.box, croppedRegions[i]), parentTrackId });
1758
- }
1759
- }
1760
- if (allDetections.length === 0)
1761
- return undefined;
1762
- const deduped = this.pipeline.runNms(allDetections);
1763
- if (deduped.length === 0)
1764
- return undefined;
1765
- return { pluginId, result: { detected: true, detections: deduped } };
1766
- };
1767
- promises.push(runClassifier());
1768
- }
1769
- return promises;
1000
+ const maxWidth = MOTION_WIDTH_MAP[this.motionResolution];
1001
+ const scaled = await this.frameScaler.scaleProportional(rawFrame, maxWidth, 'gray');
1002
+ return scaled ? this.frameScaler.toVideoFrameData(scaled) : undefined;
1770
1003
  }
1771
- async runClipDetection(croppedRegions) {
1772
- const clipPlugin = this.singlePlugins.get(SensorType.Clip);
1773
- if (!clipPlugin || croppedRegions.length === 0)
1774
- return undefined;
1775
- const batchResults = await withTimeout(clipPlugin.proxy.detectEmbeddings(this.prepareSecondaryFrames(croppedRegions)), DETECT_TIMEOUT_MS, 'CLIP');
1776
- const allEmbeddings = [];
1777
- for (let i = 0; i < batchResults.length; i++) {
1778
- if (batchResults[i].embeddings && batchResults[i].embeddings.length > 0) {
1779
- const parentTrackId = 'trackId' in croppedRegions[i].detection ? croppedRegions[i].detection.trackId : undefined;
1780
- for (const emb of batchResults[i].embeddings) {
1781
- const transformed = this.transformBoxToOriginal(emb.box, croppedRegions[i]);
1782
- allEmbeddings.push({ ...emb, box: transformed, parentTrackId });
1783
- }
1784
- }
1785
- }
1786
- if (allEmbeddings.length === 0)
1004
+ async scaleForObject(rawFrame) {
1005
+ const objectPlugin = this.plugins.get(SensorType.Object);
1006
+ if (!objectPlugin?.requiresFrames)
1787
1007
  return undefined;
1788
- const embeddingModel = clipPlugin.modelSpec?.embeddingModel ?? '';
1789
- return { embeddings: allEmbeddings, embeddingModel };
1790
- }
1791
- async generateThumbnails(sourceFrame, scaler, usingHq, objectDetections, results) {
1792
- const thumbnails = [];
1793
- const attributePadding = usingHq ? 0.4 : 0.75;
1794
- const detectionMaxWidth = usingHq ? DETECTION_THUMB_HQ_MAX_WIDTH : DETECTION_THUMB_MAX_WIDTH;
1795
- const detectionMinCrop = usingHq ? DETECTION_THUMB_HQ_MIN_CROP : DETECTION_THUMB_MIN_CROP;
1796
- const detectionQuality = usingHq ? DETECTION_THUMB_HQ_QUALITY : undefined;
1797
- // Object thumbnails — objectDetections are TrackedDetection[] when tracker ran
1798
- const objectCrops = await scaler.cropToJPEG(sourceFrame, objectDetections, {
1799
- maxWidth: detectionMaxWidth,
1800
- padding: 0.3,
1801
- minCrop: detectionMinCrop,
1802
- quality: detectionQuality,
1803
- });
1804
- for (const crop of objectCrops) {
1805
- const detection = objectDetections[crop.index];
1806
- if (detection) {
1807
- thumbnails.push({
1808
- label: detection.label,
1809
- score: detection.confidence,
1810
- jpeg: crop.jpeg,
1811
- area: detection.box.width * detection.box.height,
1812
- onEdge: this.isOnEdge(detection.box),
1813
- trackId: 'trackId' in detection ? detection.trackId : undefined,
1814
- speed: 'trackSpeed' in detection ? detection.trackSpeed : undefined,
1815
- });
1816
- }
1817
- }
1818
- if (results.face?.detections) {
1819
- const faceCrops = await scaler.cropToJPEG(sourceFrame, results.face.detections, {
1820
- maxWidth: ATTRIBUTE_THUMB_MAX_WIDTH,
1821
- padding: attributePadding,
1822
- minCrop: ATTRIBUTE_THUMB_MIN_CROP,
1823
- });
1824
- for (const crop of faceCrops) {
1825
- const detection = results.face.detections[crop.index];
1826
- if (detection) {
1827
- // Inline on the detection so enrichSegment can persist unknown
1828
- // faces to the face store.
1829
- detection.thumbnail ??= crop.jpeg;
1830
- thumbnails.push({
1831
- label: `face:${detection.identity ?? 'unknown'}`,
1832
- score: detection.confidence,
1833
- jpeg: crop.jpeg,
1834
- area: detection.box.width * detection.box.height,
1835
- onEdge: this.isOnEdge(detection.box),
1836
- });
1837
- }
1838
- }
1839
- }
1840
- if (results.licensePlate?.detections) {
1841
- const plateCrops = await scaler.cropToJPEG(sourceFrame, results.licensePlate.detections, {
1842
- maxWidth: ATTRIBUTE_THUMB_MAX_WIDTH,
1843
- padding: attributePadding,
1844
- minCrop: ATTRIBUTE_THUMB_MIN_CROP,
1845
- });
1846
- for (const crop of plateCrops) {
1847
- const detection = results.licensePlate.detections[crop.index];
1848
- if (detection) {
1849
- thumbnails.push({
1850
- label: `plate:${detection.plateText ?? 'unknown'}`,
1851
- score: detection.confidence,
1852
- jpeg: crop.jpeg,
1853
- area: detection.box.width * detection.box.height,
1854
- onEdge: this.isOnEdge(detection.box),
1855
- });
1856
- }
1857
- }
1858
- }
1859
- if (results.classifiers) {
1860
- for (const classifierResult of Object.values(results.classifiers)) {
1861
- const clsCrops = await scaler.cropToJPEG(sourceFrame, classifierResult.detections, {
1862
- maxWidth: detectionMaxWidth,
1863
- padding: 0.3,
1864
- minCrop: detectionMinCrop,
1865
- quality: detectionQuality,
1866
- });
1867
- for (const crop of clsCrops) {
1868
- const detection = classifierResult.detections[crop.index];
1869
- if (detection) {
1870
- thumbnails.push({
1871
- label: `class:${detection.label}`,
1872
- score: detection.confidence,
1873
- jpeg: crop.jpeg,
1874
- area: detection.box.width * detection.box.height,
1875
- onEdge: this.isOnEdge(detection.box),
1876
- });
1877
- }
1878
- }
1879
- }
1880
- }
1881
- return thumbnails;
1882
- }
1883
- transformBoxToOriginal(box, region) {
1884
- // box coordinates are normalized (0-1) relative to the scaled frame (e.g. 320x320),
1885
- // but must be mapped back through the original crop dimensions (before scaling)
1886
- const pixelX = box.x * region.cropSize.width + region.offset.x;
1887
- const pixelY = box.y * region.cropSize.height + region.offset.y;
1888
- const pixelW = box.width * region.cropSize.width;
1889
- const pixelH = box.height * region.cropSize.height;
1890
- return {
1891
- x: pixelX / region.originalSize.width,
1892
- y: pixelY / region.originalSize.height,
1893
- width: pixelW / region.originalSize.width,
1894
- height: pixelH / region.originalSize.height,
1895
- };
1896
- }
1897
- getNvrProxy() {
1898
- if (this.nvrProxy)
1899
- return Promise.resolve(this.nvrProxy);
1900
- this.nvrProxyPromise ??= this.resolveNvrProxy().finally(() => {
1901
- this.nvrProxyPromise = undefined;
1902
- });
1903
- return this.nvrProxyPromise;
1904
- }
1905
- async resolveNvrProxy() {
1906
- try {
1907
- const coreManagerProxy = this.proxy.createProxy(NamespaceManager.coreManagerNamespaces().coreManagerRpc);
1908
- const plugin = await coreManagerProxy.getPlugin('@camera.ui/camera-ui-nvr');
1909
- if (!plugin) {
1910
- return undefined;
1911
- }
1912
- const ns = NamespaceManager.pluginNamespaces(plugin.id);
1913
- const connection = this.proxy.createProxy(ns.pluginChildRpc, { isolatedConnection: true });
1914
- this.nvrProxy = connection.proxy;
1915
- return this.nvrProxy;
1916
- }
1917
- catch (error) {
1918
- this.logger.error('NVR proxy resolution failed:', error);
1008
+ const inputSpec = objectPlugin.modelSpec?.input;
1009
+ if (!isVideoInputSpec(inputSpec))
1919
1010
  return undefined;
1920
- }
1011
+ const scaled = await this.frameScaler.scaleToSpec(rawFrame, inputSpec);
1012
+ return scaled ? this.frameScaler.toVideoFrameData(scaled) : undefined;
1921
1013
  }
1922
1014
  };
1923
1015
  __decorate([