Add local motion detector engine
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
import { createMotionDetector } from './motion-detector';
|
||||||
import { getAppState, patchAppState, resetAppState, setAppState } from './store';
|
import { getAppState, patchAppState, resetAppState, setAppState } from './store';
|
||||||
|
|
||||||
const PAGE_PATHS = {
|
const PAGE_PATHS = {
|
||||||
@@ -79,6 +80,7 @@ let activeRecordingChunks = [];
|
|||||||
let activeRecordingStartedAt = null;
|
let activeRecordingStartedAt = null;
|
||||||
let activeRecordingStreamSessionId = null;
|
let activeRecordingStreamSessionId = null;
|
||||||
let lastMotionEventId = null;
|
let lastMotionEventId = null;
|
||||||
|
let motionDetector = null;
|
||||||
|
|
||||||
let cameraVideoElement = null;
|
let cameraVideoElement = null;
|
||||||
let clientVideoElement = null;
|
let clientVideoElement = null;
|
||||||
@@ -357,6 +359,7 @@ const attachCameraStreamToElement = () => {
|
|||||||
if (localCameraStream) {
|
if (localCameraStream) {
|
||||||
void cameraVideoElement.play().catch(() => {});
|
void cameraVideoElement.play().catch(() => {});
|
||||||
}
|
}
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachClientStreamToElement = () => {
|
const attachClientStreamToElement = () => {
|
||||||
@@ -466,6 +469,78 @@ const stopCameraPreview = () => {
|
|||||||
cameraVideoElement.srcObject = null;
|
cameraVideoElement.srcObject = null;
|
||||||
}
|
}
|
||||||
setAppState({ cameraPreviewReady: false });
|
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 = () => {
|
const clearClientStream = () => {
|
||||||
@@ -1049,6 +1124,7 @@ const connectSocket = () => {
|
|||||||
if (getAppState().device?.role === 'camera') {
|
if (getAppState().device?.role === 'camera') {
|
||||||
void startCameraPreview();
|
void startCameraPreview();
|
||||||
}
|
}
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
@@ -1056,6 +1132,7 @@ const connectSocket = () => {
|
|||||||
void stopLocalRecording();
|
void stopLocalRecording();
|
||||||
teardownPeerConnection();
|
teardownPeerConnection();
|
||||||
setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('command:received', async (payload) => {
|
socket.on('command:received', async (payload) => {
|
||||||
@@ -1281,6 +1358,9 @@ const cleanupConnectionState = async () => {
|
|||||||
await stopLocalRecording();
|
await stopLocalRecording();
|
||||||
teardownPeerConnection();
|
teardownPeerConnection();
|
||||||
stopCameraPreview();
|
stopCameraPreview();
|
||||||
|
if (motionDetector?.isRunning()) {
|
||||||
|
motionDetector.stop('idle');
|
||||||
|
}
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
socket = null;
|
socket = null;
|
||||||
@@ -1327,6 +1407,9 @@ const init = async () => {
|
|||||||
if (navigator.mediaDevices?.addEventListener) {
|
if (navigator.mediaDevices?.addEventListener) {
|
||||||
navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange);
|
navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange);
|
||||||
}
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
const saved = localStorage.getItem('mobileSimDevice');
|
const saved = localStorage.getItem('mobileSimDevice');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -1365,6 +1448,7 @@ const init = async () => {
|
|||||||
|
|
||||||
enforceRouteForSession();
|
enforceRouteForSession();
|
||||||
void refreshCameraInputDevices();
|
void refreshCameraInputDevices();
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
void cleanupConnectionState();
|
void cleanupConnectionState();
|
||||||
@@ -1383,6 +1467,9 @@ const destroy = async () => {
|
|||||||
if (navigator.mediaDevices?.removeEventListener) {
|
if (navigator.mediaDevices?.removeEventListener) {
|
||||||
navigator.mediaDevices.removeEventListener('devicechange', onMediaDeviceChange);
|
navigator.mediaDevices.removeEventListener('devicechange', onMediaDeviceChange);
|
||||||
}
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}
|
||||||
initialized = false;
|
initialized = false;
|
||||||
await cleanupConnectionState();
|
await cleanupConnectionState();
|
||||||
};
|
};
|
||||||
@@ -1585,6 +1672,7 @@ const actions = {
|
|||||||
const motionDetection = updateMotionDetectionState({ enabled: Boolean(enabled) });
|
const motionDetection = updateMotionDetectionState({ enabled: Boolean(enabled) });
|
||||||
pushToast(motionDetection.enabled ? 'Automatic detection armed' : 'Automatic detection paused', 'info');
|
pushToast(motionDetection.enabled ? 'Automatic detection armed' : 'Automatic detection paused', 'info');
|
||||||
addActivity('Motion Detection', motionDetection.enabled ? 'Detector armed' : 'Detector paused');
|
addActivity('Motion Detection', motionDetection.enabled ? 'Detector armed' : 'Detector paused');
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
},
|
},
|
||||||
|
|
||||||
setMotionDetectionProfile(profile) {
|
setMotionDetectionProfile(profile) {
|
||||||
@@ -1592,6 +1680,7 @@ const actions = {
|
|||||||
const motionDetection = updateMotionDetectionState({ profile: nextProfile.profile });
|
const motionDetection = updateMotionDetectionState({ profile: nextProfile.profile });
|
||||||
pushToast(`${nextProfile.label} profile selected`, 'success');
|
pushToast(`${nextProfile.label} profile selected`, 'success');
|
||||||
addActivity('Motion Detection', `Profile set to ${motionDetection.profile}`);
|
addActivity('Motion Detection', `Profile set to ${motionDetection.profile}`);
|
||||||
|
applyMotionDetectionReadiness();
|
||||||
},
|
},
|
||||||
|
|
||||||
setMotionDetectionDebug(debug) {
|
setMotionDetectionDebug(debug) {
|
||||||
|
|||||||
215
WebApp/src/lib/app/motion-detector.js
Normal file
215
WebApp/src/lib/app/motion-detector.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user