import { EventEmitter } from 'events'; interface MotionDetectorOptions { go2rtcUrl: string; cameraName: string; sensitivityThreshold: number; // % size change to trigger checkIntervalMs: number; } /** * Motion detection running in the main process (not throttled by browser). * Uses JPEG file size changes as a proxy for motion - more reliable than byte comparison. * When significant motion occurs, the JPEG compresses differently and size changes. */ export class MotionDetector extends EventEmitter { private options: MotionDetectorOptions; private intervalId: NodeJS.Timeout | null = null; private prevSize: number = 0; private baselineSize: number = 0; private sizeHistory: number[] = []; private isProcessing = false; private noMotionCount = 0; constructor(options: MotionDetectorOptions) { super(); this.options = options; } start(): void { if (this.intervalId) return; console.log(`MotionDetector: Starting detection on ${this.options.cameraName}`); this.intervalId = setInterval(() => { this.checkMotion(); }, this.options.checkIntervalMs); // Do initial checks to establish baseline this.checkMotion(); } stop(): void { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.prevSize = 0; this.baselineSize = 0; this.sizeHistory = []; console.log('MotionDetector: Stopped'); } private async checkMotion(): Promise { if (this.isProcessing) return; this.isProcessing = true; try { const frameData = await this.fetchFrame(); if (!frameData) { this.isProcessing = false; return; } const currentSize = frameData.length; // Build up history for baseline (first 5 frames) if (this.sizeHistory.length < 5) { this.sizeHistory.push(currentSize); if (this.sizeHistory.length === 5) { // Calculate baseline as average of first 5 frames this.baselineSize = this.sizeHistory.reduce((a, b) => a + b, 0) / 5; console.log(`MotionDetector: Baseline established at ${this.baselineSize} bytes`); } this.prevSize = currentSize; this.isProcessing = false; return; } // Compare to previous frame AND baseline const prevDiff = Math.abs(currentSize - this.prevSize); const baselineDiff = Math.abs(currentSize - this.baselineSize); const prevChangePercent = (prevDiff / this.prevSize) * 100; const baselineChangePercent = (baselineDiff / this.baselineSize) * 100; // Motion detected if EITHER: // 1. Frame-to-frame change is significant (something just moved) // 2. Deviation from baseline is significant (scene has changed) const motionDetected = prevChangePercent > this.options.sensitivityThreshold || baselineChangePercent > (this.options.sensitivityThreshold * 2); if (motionDetected) { this.noMotionCount = 0; console.log(`MotionDetector: Motion detected (prev=${prevChangePercent.toFixed(1)}% baseline=${baselineChangePercent.toFixed(1)}%)`); this.emit('motion'); } else { this.noMotionCount++; // Update baseline slowly when no motion (adapt to lighting changes) if (this.noMotionCount > 10) { this.baselineSize = this.baselineSize * 0.95 + currentSize * 0.05; } } this.prevSize = currentSize; } catch (error) { console.error('MotionDetector: Error checking motion:', error); } finally { this.isProcessing = false; } } private async fetchFrame(): Promise { // Try up to 2 times with a short delay for (let attempt = 0; attempt < 2; attempt++) { try { const url = `${this.options.go2rtcUrl}/api/frame.jpeg?src=${this.options.cameraName}&t=${Date.now()}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { continue; } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } catch { if (attempt === 0) { await new Promise(r => setTimeout(r, 500)); } } } return null; } }