Add local motion detector engine

This commit is contained in:
2026-03-08 12:40:00 +00:00
parent 3c2ea7fd75
commit 72903a97eb
2 changed files with 304 additions and 0 deletions

View File

@@ -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) {

View 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
};
}
};
};