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