138 lines
4.3 KiB
TypeScript
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;
|
|
}
|
|
}
|