Add motion detection controls and persistence
This commit is contained in:
@@ -14,6 +14,46 @@ const PAGE_PATHS = {
|
||||
settings: '/settings'
|
||||
};
|
||||
|
||||
const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings';
|
||||
const MOTION_DETECTION_PROFILES = {
|
||||
low_power: {
|
||||
profile: 'low_power',
|
||||
label: 'Low Power',
|
||||
description: 'Least heat and battery usage, slower trigger response.',
|
||||
sampleIntervalMs: 1400,
|
||||
burstIntervalMs: 500,
|
||||
triggerThreshold: 0.15,
|
||||
releaseThreshold: 0.05,
|
||||
consecutiveTriggerFrames: 3,
|
||||
cooldownMs: 12000,
|
||||
minimumEventMs: 9000
|
||||
},
|
||||
balanced: {
|
||||
profile: 'balanced',
|
||||
label: 'Balanced',
|
||||
description: 'Recommended default for a plugged-in foreground browser.',
|
||||
sampleIntervalMs: 1000,
|
||||
burstIntervalMs: 300,
|
||||
triggerThreshold: 0.12,
|
||||
releaseThreshold: 0.04,
|
||||
consecutiveTriggerFrames: 3,
|
||||
cooldownMs: 9000,
|
||||
minimumEventMs: 8000
|
||||
},
|
||||
responsive: {
|
||||
profile: 'responsive',
|
||||
label: 'Responsive',
|
||||
description: 'Faster trigger response with higher CPU and thermal cost.',
|
||||
sampleIntervalMs: 700,
|
||||
burstIntervalMs: 220,
|
||||
triggerThreshold: 0.1,
|
||||
releaseThreshold: 0.035,
|
||||
consecutiveTriggerFrames: 2,
|
||||
cooldownMs: 7000,
|
||||
minimumEventMs: 7000
|
||||
}
|
||||
};
|
||||
|
||||
const RECORDING_VIDEO_BITS_PER_SECOND = 850_000;
|
||||
const COMPRESSED_UPLOAD_MAX_WIDTH = 640;
|
||||
const COMPRESSED_UPLOAD_MAX_HEIGHT = 360;
|
||||
@@ -57,6 +97,110 @@ const makeId = () => {
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const getDefaultMotionDetectionState = () => ({
|
||||
enabled: false,
|
||||
profile: MOTION_DETECTION_PROFILES.balanced.profile,
|
||||
state: 'idle',
|
||||
score: 0,
|
||||
debug: false,
|
||||
lastTriggeredAt: null
|
||||
});
|
||||
|
||||
const getMotionDetectionProfile = (profile) =>
|
||||
MOTION_DETECTION_PROFILES[profile] ?? MOTION_DETECTION_PROFILES.balanced;
|
||||
|
||||
const buildMotionDetectionState = (overrides = {}) => {
|
||||
const defaults = getDefaultMotionDetectionState();
|
||||
const nextProfile = getMotionDetectionProfile(overrides.profile ?? defaults.profile);
|
||||
return {
|
||||
...defaults,
|
||||
...nextProfile,
|
||||
...overrides,
|
||||
profile: nextProfile.profile
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeMotionDetectionSettings = (value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return buildMotionDetectionState();
|
||||
}
|
||||
|
||||
return buildMotionDetectionState({
|
||||
enabled: Boolean(value.enabled),
|
||||
profile: typeof value.profile === 'string' ? value.profile : undefined,
|
||||
debug: Boolean(value.debug)
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeMotionDetectionState = (value) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return buildMotionDetectionState();
|
||||
}
|
||||
|
||||
return buildMotionDetectionState({
|
||||
enabled: Boolean(value.enabled),
|
||||
profile: typeof value.profile === 'string' ? value.profile : undefined,
|
||||
state: typeof value.state === 'string' ? value.state : undefined,
|
||||
score: typeof value.score === 'number' ? value.score : undefined,
|
||||
debug: Boolean(value.debug),
|
||||
lastTriggeredAt: typeof value.lastTriggeredAt === 'string' ? value.lastTriggeredAt : null
|
||||
});
|
||||
};
|
||||
|
||||
const loadMotionDetectionSettings = () => {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return buildMotionDetectionState();
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(MOTION_DETECTION_SETTINGS_STORAGE_KEY);
|
||||
if (!saved) {
|
||||
return buildMotionDetectionState();
|
||||
}
|
||||
return sanitizeMotionDetectionSettings(JSON.parse(saved));
|
||||
} catch (error) {
|
||||
console.error('Failed to load motion detection settings', error);
|
||||
return buildMotionDetectionState();
|
||||
}
|
||||
};
|
||||
|
||||
const persistMotionDetectionSettings = (motionDetection) => {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
MOTION_DETECTION_SETTINGS_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
enabled: Boolean(motionDetection?.enabled),
|
||||
profile: motionDetection?.profile ?? MOTION_DETECTION_PROFILES.balanced.profile,
|
||||
debug: Boolean(motionDetection?.debug)
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save motion detection settings', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMotionDetectionState = (updates) => {
|
||||
const current = normalizeMotionDetectionState(getAppState().motionDetection);
|
||||
const next =
|
||||
typeof updates === 'function'
|
||||
? normalizeMotionDetectionState({
|
||||
...current,
|
||||
...updates(current)
|
||||
})
|
||||
: normalizeMotionDetectionState({
|
||||
...current,
|
||||
...updates
|
||||
});
|
||||
|
||||
setAppState({ motionDetection: next });
|
||||
persistMotionDetectionSettings(next);
|
||||
return next;
|
||||
};
|
||||
|
||||
const normalizePath = (path) => path.replace(/\/+$/, '') || '/';
|
||||
|
||||
const getCurrentPath = () => normalizePath(window.location.pathname);
|
||||
@@ -781,7 +925,8 @@ const finalizeRecordingForStream = async (streamSessionId, captureResult) => {
|
||||
body: JSON.stringify({
|
||||
fileName: `stream-${streamSessionId}.webm`,
|
||||
deviceId: currentDevice.id,
|
||||
prefix: 'recordings'
|
||||
prefix: 'recordings',
|
||||
recordingId: recording.id
|
||||
})
|
||||
});
|
||||
|
||||
@@ -843,7 +988,8 @@ const uploadStandaloneMotionRecording = async (captureResult) => {
|
||||
body: JSON.stringify({
|
||||
fileName: `motion-${Date.now()}.webm`,
|
||||
deviceId: currentDevice.id,
|
||||
prefix: 'recordings'
|
||||
prefix: 'recordings',
|
||||
eventId: lastMotionEventId
|
||||
})
|
||||
});
|
||||
|
||||
@@ -857,6 +1003,13 @@ const uploadStandaloneMotionRecording = async (captureResult) => {
|
||||
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
await api.events.finalizeRecording(uploadMeta.video.id, {
|
||||
objectKey: uploadMeta.objectKey,
|
||||
bucket: uploadMeta.bucket,
|
||||
durationSeconds: captureResult.durationSeconds,
|
||||
sizeBytes: compressedBlob.size
|
||||
});
|
||||
|
||||
addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -1193,6 +1346,8 @@ const init = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
setAppState({ motionDetection: loadMotionDetectionSettings() });
|
||||
|
||||
try {
|
||||
const session = await api.auth.getSession();
|
||||
if (session?.session) {
|
||||
@@ -1426,6 +1581,24 @@ const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
setMotionDetectionEnabled(enabled) {
|
||||
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');
|
||||
},
|
||||
|
||||
setMotionDetectionProfile(profile) {
|
||||
const nextProfile = getMotionDetectionProfile(profile);
|
||||
const motionDetection = updateMotionDetectionState({ profile: nextProfile.profile });
|
||||
pushToast(`${nextProfile.label} profile selected`, 'success');
|
||||
addActivity('Motion Detection', `Profile set to ${motionDetection.profile}`);
|
||||
},
|
||||
|
||||
setMotionDetectionDebug(debug) {
|
||||
const motionDetection = updateMotionDetectionState({ debug: Boolean(debug) });
|
||||
pushToast(motionDetection.debug ? 'Motion debug enabled' : 'Motion debug hidden', 'info');
|
||||
},
|
||||
|
||||
async linkCamera() {
|
||||
const id = prompt('Enter Camera Device ID:');
|
||||
if (!id) return;
|
||||
|
||||
@@ -40,6 +40,14 @@ export const createInitialState = () => ({
|
||||
title: 'Recording Playback',
|
||||
url: ''
|
||||
},
|
||||
motionDetection: {
|
||||
enabled: false,
|
||||
profile: 'balanced',
|
||||
state: 'idle',
|
||||
score: 0,
|
||||
debug: false,
|
||||
lastTriggeredAt: null
|
||||
},
|
||||
clientStreamMode: 'none',
|
||||
clientPlaceholderText: 'Select a camera to view',
|
||||
lastError: null
|
||||
|
||||
Reference in New Issue
Block a user