Add local motion detector engine
This commit is contained in:
@@ -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) {
|
||||
|
||||
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