Files

138 lines
4.3 KiB
TypeScript

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<void> {
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<Buffer | null> {
// 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;
}
}