From 72903a97ebfcf6341e2b1560b52cd84a6bfdae24 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Sun, 8 Mar 2026 12:40:00 +0000 Subject: [PATCH] Add local motion detector engine --- WebApp/src/lib/app/controller.js | 89 +++++++++++ WebApp/src/lib/app/motion-detector.js | 215 ++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 WebApp/src/lib/app/motion-detector.js diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js index e4a269d..9a2a53a 100644 --- a/WebApp/src/lib/app/controller.js +++ b/WebApp/src/lib/app/controller.js @@ -3,6 +3,7 @@ import { io } from 'socket.io-client'; import { api } from './api'; +import { createMotionDetector } from './motion-detector'; import { getAppState, patchAppState, resetAppState, setAppState } from './store'; const PAGE_PATHS = { @@ -79,6 +80,7 @@ let activeRecordingChunks = []; let activeRecordingStartedAt = null; let activeRecordingStreamSessionId = null; let lastMotionEventId = null; +let motionDetector = null; let cameraVideoElement = null; let clientVideoElement = null; @@ -357,6 +359,7 @@ const attachCameraStreamToElement = () => { if (localCameraStream) { void cameraVideoElement.play().catch(() => {}); } + applyMotionDetectionReadiness(); }; const attachClientStreamToElement = () => { @@ -466,6 +469,78 @@ const stopCameraPreview = () => { cameraVideoElement.srcObject = null; } setAppState({ cameraPreviewReady: false }); + applyMotionDetectionReadiness(); +}; + +const updateMotionDetectionRuntime = (updates) => { + patchAppState((state) => ({ + motionDetection: { + ...state.motionDetection, + ...updates + } + })); +}; + +const ensureMotionDetector = () => { + if (motionDetector) { + return motionDetector; + } + + motionDetector = createMotionDetector({ + getSourceElement: () => cameraVideoElement, + getConfig: () => getAppState().motionDetection, + onUpdate: ({ state, score, activeMotion, activeSince }) => { + const current = getAppState().motionDetection; + const nextState = state === 'cooldown' && !activeMotion ? 'monitoring' : state; + updateMotionDetectionRuntime({ + state: nextState, + score, + lastTriggeredAt: activeMotion + ? current.lastTriggeredAt ?? (activeSince ? new Date(activeSince).toISOString() : new Date().toISOString()) + : current.lastTriggeredAt + }); + } + }); + + return motionDetector; +}; + +const shouldRunMotionDetector = () => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return false; + } + + const state = getAppState(); + return Boolean( + state.device?.role === 'camera' && + state.motionDetection?.enabled && + state.cameraPreviewReady && + localCameraStream && + cameraVideoElement + ); +}; + +const applyMotionDetectionReadiness = () => { + const detector = ensureMotionDetector(); + if (shouldRunMotionDetector()) { + if (!detector.isRunning()) { + detector.start(); + addActivity('Motion Detection', 'Detector monitoring started'); + } + return; + } + + if (detector.isRunning()) { + detector.stop('idle'); + addActivity('Motion Detection', 'Detector monitoring paused'); + return; + } + + updateMotionDetectionRuntime({ state: 'idle', score: 0 }); +}; + +const onVisibilityChange = () => { + applyMotionDetectionReadiness(); }; const clearClientStream = () => { @@ -1049,6 +1124,7 @@ const connectSocket = () => { if (getAppState().device?.role === 'camera') { void startCameraPreview(); } + applyMotionDetectionReadiness(); }); socket.on('disconnect', () => { @@ -1056,6 +1132,7 @@ const connectSocket = () => { void stopLocalRecording(); teardownPeerConnection(); setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null }); + applyMotionDetectionReadiness(); }); socket.on('command:received', async (payload) => { @@ -1281,6 +1358,9 @@ const cleanupConnectionState = async () => { await stopLocalRecording(); teardownPeerConnection(); stopCameraPreview(); + if (motionDetector?.isRunning()) { + motionDetector.stop('idle'); + } if (socket) { socket.disconnect(); socket = null; @@ -1327,6 +1407,9 @@ const init = async () => { if (navigator.mediaDevices?.addEventListener) { navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange); } + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibilityChange); + } const saved = localStorage.getItem('mobileSimDevice'); if (saved) { @@ -1365,6 +1448,7 @@ const init = async () => { enforceRouteForSession(); void refreshCameraInputDevices(); + applyMotionDetectionReadiness(); window.addEventListener('beforeunload', () => { void cleanupConnectionState(); @@ -1383,6 +1467,9 @@ const destroy = async () => { if (navigator.mediaDevices?.removeEventListener) { navigator.mediaDevices.removeEventListener('devicechange', onMediaDeviceChange); } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibilityChange); + } initialized = false; await cleanupConnectionState(); }; @@ -1585,6 +1672,7 @@ const actions = { const motionDetection = updateMotionDetectionState({ enabled: Boolean(enabled) }); pushToast(motionDetection.enabled ? 'Automatic detection armed' : 'Automatic detection paused', 'info'); addActivity('Motion Detection', motionDetection.enabled ? 'Detector armed' : 'Detector paused'); + applyMotionDetectionReadiness(); }, setMotionDetectionProfile(profile) { @@ -1592,6 +1680,7 @@ const actions = { const motionDetection = updateMotionDetectionState({ profile: nextProfile.profile }); pushToast(`${nextProfile.label} profile selected`, 'success'); addActivity('Motion Detection', `Profile set to ${motionDetection.profile}`); + applyMotionDetectionReadiness(); }, setMotionDetectionDebug(debug) { diff --git a/WebApp/src/lib/app/motion-detector.js b/WebApp/src/lib/app/motion-detector.js new file mode 100644 index 0000000..3beb1e6 --- /dev/null +++ b/WebApp/src/lib/app/motion-detector.js @@ -0,0 +1,215 @@ +// @ts-nocheck + +const DEFAULT_FRAME_WIDTH = 160; +const DEFAULT_FRAME_HEIGHT = 90; +const PIXEL_DELTA_THRESHOLD = 18; +const DEFAULT_SMOOTHING_FACTOR = 0.35; + +const clampScore = (value) => { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +}; + +const computeLuma = (imageData) => { + const luminance = new Float32Array(imageData.data.length / 4); + for (let offset = 0, index = 0; offset < imageData.data.length; offset += 4, index += 1) { + luminance[index] = + imageData.data[offset] * 0.299 + imageData.data[offset + 1] * 0.587 + imageData.data[offset + 2] * 0.114; + } + return luminance; +}; + +export const createMotionDetector = ({ + getSourceElement, + getConfig, + onUpdate, + frameWidth = DEFAULT_FRAME_WIDTH, + frameHeight = DEFAULT_FRAME_HEIGHT, + smoothingFactor = DEFAULT_SMOOTHING_FACTOR +} = {}) => { + const canvas = document.createElement('canvas'); + canvas.width = frameWidth; + canvas.height = frameHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + + let active = false; + let timerId = null; + let previousFrame = null; + let smoothedScore = 0; + let consecutiveHighFrames = 0; + let activeMotion = false; + let quietSince = null; + let activeSince = null; + let currentState = 'idle'; + + const emitUpdate = (updates = {}) => { + const score = clampScore(updates.score ?? smoothedScore); + currentState = updates.state ?? currentState; + onUpdate?.({ + state: currentState, + score, + activeMotion, + activeSince, + quietSince + }); + }; + + const clearTimer = () => { + if (timerId) { + clearTimeout(timerId); + timerId = null; + } + }; + + const reset = (state = 'idle') => { + clearTimer(); + previousFrame = null; + smoothedScore = 0; + consecutiveHighFrames = 0; + activeMotion = false; + quietSince = null; + activeSince = null; + emitUpdate({ state, score: 0 }); + }; + + const scheduleNextTick = (ms) => { + clearTimer(); + timerId = setTimeout(() => { + void tick(); + }, Math.max(60, ms)); + }; + + const analyzeFrame = () => { + if (!context) { + return { state: 'error', score: 0 }; + } + + const sourceElement = getSourceElement?.(); + if ( + !sourceElement || + sourceElement.readyState < 2 || + !sourceElement.videoWidth || + !sourceElement.videoHeight + ) { + return { state: 'warming_up', score: 0 }; + } + + context.drawImage(sourceElement, 0, 0, frameWidth, frameHeight); + const imageData = context.getImageData(0, 0, frameWidth, frameHeight); + const currentFrame = computeLuma(imageData); + + if (!previousFrame) { + previousFrame = currentFrame; + return { state: 'warming_up', score: 0 }; + } + + let changedPixels = 0; + for (let index = 0; index < currentFrame.length; index += 1) { + if (Math.abs(currentFrame[index] - previousFrame[index]) >= PIXEL_DELTA_THRESHOLD) { + changedPixels += 1; + } + } + + const rawScore = changedPixels / currentFrame.length; + previousFrame = currentFrame; + smoothedScore = clampScore(smoothedScore * (1 - smoothingFactor) + rawScore * smoothingFactor); + + return { + state: 'monitoring', + score: smoothedScore + }; + }; + + const applyStateMachine = (analysis, config) => { + const now = Date.now(); + const score = clampScore(analysis.score); + const triggerThreshold = config.triggerThreshold ?? 0.12; + const releaseThreshold = config.releaseThreshold ?? 0.04; + const consecutiveRequired = config.consecutiveTriggerFrames ?? 3; + const minimumEventMs = config.minimumEventMs ?? 8000; + const cooldownMs = config.cooldownMs ?? 9000; + + if (analysis.state === 'warming_up') { + consecutiveHighFrames = 0; + if (!activeMotion) { + quietSince = null; + return { state: 'warming_up', score }; + } + } + + if (!activeMotion) { + if (score >= triggerThreshold) { + consecutiveHighFrames += 1; + if (consecutiveHighFrames >= consecutiveRequired) { + activeMotion = true; + activeSince = now; + quietSince = null; + return { state: 'triggered', score }; + } + return { state: 'candidate_motion', score }; + } + + consecutiveHighFrames = 0; + return { state: analysis.state === 'warming_up' ? 'warming_up' : 'monitoring', score }; + } + + if (score >= releaseThreshold) { + quietSince = null; + return { state: 'triggered', score }; + } + + if (!quietSince) { + quietSince = now; + } + + if (now - (activeSince ?? now) < minimumEventMs) { + return { state: 'triggered', score }; + } + + if (now - quietSince >= cooldownMs) { + activeMotion = false; + activeSince = null; + quietSince = null; + consecutiveHighFrames = 0; + return { state: 'cooldown', score }; + } + + return { state: 'cooldown', score }; + }; + + const tick = async () => { + if (!active) return; + + const config = getConfig?.() ?? {}; + const analysis = analyzeFrame(); + const next = applyStateMachine(analysis, config); + emitUpdate(next); + + const activeInterval = next.state === 'candidate_motion' || next.state === 'triggered' || next.state === 'cooldown'; + scheduleNextTick(activeInterval ? config.burstIntervalMs ?? 300 : config.sampleIntervalMs ?? 1000); + }; + + return { + start() { + if (active) return; + active = true; + reset('warming_up'); + scheduleNextTick(0); + }, + stop(reason = 'idle') { + active = false; + reset(reason); + }, + isRunning() { + return active; + }, + getSnapshot() { + return { + active, + state: currentState, + score: clampScore(smoothedScore), + activeMotion + }; + } + }; +};