diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js index 7724b17..e4a269d 100644 --- a/WebApp/src/lib/app/controller.js +++ b/WebApp/src/lib/app/controller.js @@ -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; diff --git a/WebApp/src/lib/app/store.js b/WebApp/src/lib/app/store.js index 4e6b1f6..4efb157 100644 --- a/WebApp/src/lib/app/store.js +++ b/WebApp/src/lib/app/store.js @@ -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 diff --git a/WebApp/src/routes/camera/+page.svelte b/WebApp/src/routes/camera/+page.svelte index 6ef3716..90cf741 100644 --- a/WebApp/src/routes/camera/+page.svelte +++ b/WebApp/src/routes/camera/+page.svelte @@ -3,18 +3,44 @@ import AppChrome from '$lib/sim/ui/AppChrome.svelte'; import { appController } from '$lib/app/controller'; import { appState } from '$lib/app/store'; + import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; + import { Badge } from '$lib/components/ui/badge/index.js'; + import { Button } from '$lib/components/ui/button/index.js'; + import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js'; + import { Label } from '$lib/components/ui/label/index.js'; + import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; + import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select/index.js'; let cameraVideoElement: HTMLVideoElement | null = null; $effect(() => { appController.setCameraVideoElement(cameraVideoElement); }); + + const cameraInputItems = () => + $appState.cameraInputDevices.map((cameraInput) => ({ + value: cameraInput.id, + label: cameraInput.label + })); + + const selectedCameraInputLabel = () => + $appState.cameraInputDevices.find((cameraInput) => cameraInput.id === $appState.selectedCameraInputId)?.label || + ($appState.cameraInputDevices.length > 0 ? 'Choose camera input' : 'No camera inputs found'); + + const motionDetectionProfileItems = [ + { value: 'low_power', label: 'Low Power' }, + { value: 'balanced', label: 'Balanced' }, + { value: 'responsive', label: 'Responsive' } + ]; + + const motionDetectionProfileLabel = () => + motionDetectionProfileItems.find((item) => item.value === $appState.motionDetection.profile)?.label || 'Balanced';
-
+
-
- REC -
+ REC +
- - - -

Camera Offline

- + +
+
-
+
-
-

Logs

-
+ + Logs + + + {#if $appState.activityLog.length === 0}
Awaiting connection...
@@ -69,66 +107,157 @@
[{new Date(log.createdAt).toLocaleTimeString()}] {log.type}: {log.message}
{/each} {/if} -
-
+ + + -
-

Actions / Options

-
+ + + Actions / Options + + +
-
- -
-
+ + +
+
+
+
+

Automatic Detection

+

+ {$appState.motionDetection.enabled ? 'Armed for automatic motion events.' : 'Paused until manually armed.'} +

+

+ Designed for a plugged-in browser kept in the foreground. +

+
+ +
+ +
+
+ + {$appState.motionDetection.state} +
+ +
+ +
+
+

Detector State

+

{$appState.motionDetection.state}

+
+
+

Motion Score

+

{$appState.motionDetection.score.toFixed(3)}

+
+
+ +
+
+

Show detector debug info

+

Useful while tuning thresholds and sample profiles.

+
+ +
+
+
{#if !$appState.isMotionActive} - + {:else} - + {/if} -
-
+
+ +