simple-movement-detector 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/index.d.ts +83 -0
  4. package/index.js +171 -0
  5. package/package.json +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Mikhail Gorbunov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # simple-movement-detector
2
+
3
+ ![](https://img.shields.io/badge/dependencies-zero-green) [![npm version](https://img.shields.io/npm/v/simple-movement-detector.svg)](https://www.npmjs.com/package/simple-movement-detector) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
+
5
+ A zero-dependency browser library that calls your callback when the webcam detects movement.
6
+
7
+ - Zero dependencies
8
+ - One function: `detectMovement(onMovement, config)`
9
+ - ~3 KB, no build step, no WebAssembly, no Web Workers
10
+ - ES module + TypeScript definitions
11
+
12
+ ```js
13
+ import { detectMovement } from 'simple-movement-detector';
14
+
15
+ const stop = detectMovement((snapshot) => {
16
+ console.log('Movement detected!', snapshot);
17
+ });
18
+
19
+ stop();
20
+ ```
21
+
22
+ ## Try the demo
23
+
24
+ - [Full demo](https://web-detect-movement.vercel.app/demo/demo.html) — with config controls, presets, and diff visualization
25
+ - [Minimal demo](https://web-detect-movement.vercel.app/demo/minimal-demo.html) — the smallest possible usage
26
+ - [Spy on your dog](https://web-detect-movement.vercel.app/demo/spy-on-your-dog.html) — records video on movement, downloads after 15s of stillness
27
+
28
+ ## When to use it
29
+
30
+ You want a callback when something moves in front of the camera. You don't need a sensitivity matrix, a motion grid, or a per-frame diff image. Common use cases:
31
+
32
+ - Spy on your dog
33
+ - Turn on a light / trigger an action when someone walks into frame
34
+ - Send a snapshot to your server when motion is detected
35
+ - Build a simple security cam, doorbell, or presence detector in the browser
36
+
37
+ ### When to use something else
38
+
39
+ If you need **motion analysis** — where motion happened, how much, a per-frame matrix for interactive visualizers — use [Diffy.js](https://github.com/maniart/diffyjs)
40
+
41
+ ## Usage
42
+
43
+ ### 1. Install
44
+
45
+ ```sh
46
+ npm install simple-movement-detector
47
+ ```
48
+
49
+ ### 2. Detect movement
50
+
51
+ ```js
52
+ import { detectMovement } from 'simple-movement-detector';
53
+
54
+ const stop = detectMovement((snapshot) => {
55
+ // snapshot is a PNG data URL of the frame where motion was detected
56
+ fetch('/alert', { method: 'POST', body: snapshot });
57
+ });
58
+
59
+ stop();
60
+ ```
61
+
62
+ ### 3. (Optional) Tune sensitivity
63
+
64
+ ```js
65
+ // Catches even tiny motion — a hand waving, a curtain moving
66
+ detectMovement(onMovement, { threshold: 0.02, pixelThreshold: 15, interval: 500 });
67
+
68
+ // The default — a dog walking through frame
69
+ detectMovement(onMovement, { threshold: 0.1, pixelThreshold: 30, interval: 1000 });
70
+
71
+ // Only fires on big motion — a person walking across the frame
72
+ detectMovement(onMovement, { threshold: 0.25, pixelThreshold: 50, interval: 1000 });
73
+ ```
74
+
75
+ ### Without a bundler
76
+
77
+ ```html
78
+ <script type="module">
79
+ import { detectMovement } from 'https://esm.sh/simple-movement-detector';
80
+
81
+ detectMovement((snapshot) => {
82
+ document.getElementById('img').src = snapshot;
83
+ });
84
+ </script>
85
+ ```
86
+
87
+ Also available via [jsdelivr](https://cdn.jsdelivr.net/npm/simple-movement-detector/+esm) and [unpkg](https://unpkg.com/simple-movement-detector/index.js).
88
+
89
+ ## Configuration
90
+
91
+ All options are optional.
92
+
93
+ | Option | Type | Default | Description |
94
+ |--------|------|---------|-------------|
95
+ | `threshold` | `number` | `0.1` | Fraction of pixels (0-1) that must change to trigger. `0.1` = 10%. |
96
+ | `pixelThreshold` | `number` | `30` | Per-pixel avg RGB diff (0-255) for a pixel to count as "changed". Filters camera noise. |
97
+ | `interval` | `number` | `1000` | Milliseconds between checks. |
98
+ | `width` | `number` | `320` | Capture width. Smaller is faster. |
99
+ | `height` | `number` | `240` | Capture height. Smaller is faster. |
100
+ | `videoElement` | `HTMLVideoElement` | — | Existing `<video>` with a stream. If omitted, webcam is launched. |
101
+ | `debugCanvas` | `HTMLCanvasElement` | — | Canvas to draw the diff onto (changed pixels in red). Off by default. |
102
+
103
+ ## How it works
104
+
105
+ Every `interval` ms, the library draws the webcam frame to an offscreen canvas and reads the pixels. Each pixel is compared to the previous frame: if the avg RGB diff exceeds `pixelThreshold`, it counts as changed. If changed pixels exceed `threshold * width * height`, `onMovement` fires with a PNG snapshot.
106
+
107
+ Frame buffers are `Uint8ClampedArray` in memory — no `<img>` elements, no PNG encode/decode per frame. The only PNG encode happens when motion is detected.
108
+
109
+ ## Browser requirements
110
+
111
+ - Modern browser with `getUserMedia` (Chrome, Firefox, Safari, Edge)
112
+ - **HTTPS or localhost** — camera access is blocked on insecure origins
113
+ - iOS Safari needs `playsinline` (handled automatically)
114
+
115
+ ## Common issues
116
+
117
+ ### Camera does not start
118
+
119
+ `getUserMedia` only works on `https://` or `http://localhost`. `file://` won't work.
120
+
121
+ ### Too many false positives
122
+
123
+ Increase `pixelThreshold` (try 40-50) or `threshold` (try 0.2).
124
+
125
+ ### Motion is detected too late
126
+
127
+ Decrease `interval` (try 250ms). Smaller intervals use more CPU.
128
+
129
+ ### Motion is never detected
130
+
131
+ Decrease `pixelThreshold` (try 15-20) or `threshold` (try 0.02-0.05). If you pass your own `videoElement`, make sure it has a live `srcObject`.
132
+
133
+ ### I want to see what the detector sees
134
+
135
+ Pass a `debugCanvas` in the config. Changed pixels are painted red, unchanged black, every tick.
136
+
137
+ ## License
138
+
139
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Configuration for detectMovement.
3
+ */
4
+ export interface DetectMovementConfig {
5
+ /**
6
+ * Fraction of pixels that must change (0-1) to trigger a movement event.
7
+ * 0.1 means 10% of pixels must differ significantly from the previous frame.
8
+ * @default 0.1
9
+ */
10
+ threshold?: number;
11
+
12
+ /**
13
+ * Per-pixel average RGB difference (0-255) for a single pixel to count as "changed".
14
+ * Filters out camera noise. 30 means a pixel must differ by ~30 in average RGB.
15
+ * @default 30
16
+ */
17
+ pixelThreshold?: number;
18
+
19
+ /**
20
+ * Milliseconds between motion checks.
21
+ * @default 1000
22
+ */
23
+ interval?: number;
24
+
25
+ /**
26
+ * Capture width in pixels. Smaller is faster.
27
+ * @default 320
28
+ */
29
+ width?: number;
30
+
31
+ /**
32
+ * Capture height in pixels. Smaller is faster.
33
+ * @default 240
34
+ */
35
+ height?: number;
36
+
37
+ /**
38
+ * Optional existing `<video>` element already playing a camera stream.
39
+ * If omitted, the webcam is launched automatically via getUserMedia.
40
+ */
41
+ videoElement?: HTMLVideoElement;
42
+
43
+ /**
44
+ * Optional canvas to draw the diff visualization onto (changed pixels in red).
45
+ * Off by default — only allocated when provided, to avoid wasted work.
46
+ */
47
+ debugCanvas?: HTMLCanvasElement;
48
+ }
49
+
50
+ /**
51
+ * Callback invoked when movement is detected.
52
+ * @param snapshot - A PNG data URL of the frame where motion was detected.
53
+ */
54
+ export type OnMovement = (snapshot: string) => void;
55
+
56
+ /**
57
+ * Zero-dependency browser motion detection.
58
+ *
59
+ * Calls `onMovement` with a PNG snapshot whenever the webcam detects movement
60
+ * above the configured threshold. Returns a `stop` function to clean up.
61
+ *
62
+ * @example
63
+ * ```js
64
+ * import { detectMovement } from 'simple-movement-detector';
65
+ *
66
+ * const stop = detectMovement((snapshot) => {
67
+ * console.log('Movement detected!', snapshot);
68
+ * });
69
+ *
70
+ * // later:
71
+ * stop();
72
+ * ```
73
+ *
74
+ * @param onMovement - Called with a PNG data URL of the triggering frame.
75
+ * @param config - Optional configuration. All fields have sensible defaults.
76
+ * @returns A `stop` function. Call it to stop detection, clear the interval, and release the camera.
77
+ */
78
+ export declare function detectMovement(
79
+ onMovement: OnMovement,
80
+ config?: DetectMovementConfig
81
+ ): () => void;
82
+
83
+ export default detectMovement;
package/index.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * simple-movement-detector
3
+ * Zero-dependency browser motion detection. Calls your callback when the webcam sees movement.
4
+ *
5
+ * @param {function(string): void} onMovement - Called with a PNG data URL of the frame where motion was detected.
6
+ * @param {object} [config]
7
+ * @param {number} [config.threshold=0.1] - Fraction of pixels that must change (0-1) to trigger movement.
8
+ * @param {number} [config.pixelThreshold=30] - Per-pixel average RGB diff (0-255) for a pixel to count as "changed".
9
+ * @param {number} [config.interval=1000] - Milliseconds between motion checks.
10
+ * @param {number} [config.width=320] - Capture width in pixels.
11
+ * @param {number} [config.height=240] - Capture height in pixels.
12
+ * @param {HTMLVideoElement} [config.videoElement] - Optional existing <video> element with a stream. If omitted, the webcam is launched.
13
+ * @param {HTMLCanvasElement} [config.debugCanvas] - Optional canvas to draw the diff visualization onto. Off by default.
14
+ * @returns {function(): void} stop - Call to stop detection, clear the interval, and stop the camera track if we launched it.
15
+ */
16
+ export function detectMovement(onMovement, config = {}) {
17
+ if (typeof onMovement !== 'function') {
18
+ throw new TypeError('detectMovement: onMovement callback is required');
19
+ }
20
+ if (typeof window === 'undefined' || !navigator || !navigator.mediaDevices) {
21
+ throw new Error('detectMovement must be used in a browser with getUserMedia support');
22
+ }
23
+
24
+ const width = config.width || 320;
25
+ const height = config.height || 240;
26
+ const interval = config.interval ?? 1000;
27
+ const pixelThreshold = config.pixelThreshold ?? 30;
28
+ const imageThreshold = config.threshold ?? 0.1;
29
+
30
+ if (imageThreshold < 0 || imageThreshold > 1) {
31
+ throw new RangeError('detectMovement: threshold must be between 0 and 1');
32
+ }
33
+ if (pixelThreshold < 0 || pixelThreshold > 255) {
34
+ throw new RangeError('detectMovement: pixelThreshold must be between 0 and 255');
35
+ }
36
+ const changedPixelsRequired = Math.floor(width * height * imageThreshold);
37
+
38
+ // Capture canvas (video -> pixels). Never attached to the DOM.
39
+ const captureCanvas = document.createElement('canvas');
40
+ captureCanvas.width = width;
41
+ captureCanvas.height = height;
42
+ const captureCtx = captureCanvas.getContext('2d', { willReadFrequently: true });
43
+
44
+ // Optional debug canvas for diff visualization. Only allocated if user passes one.
45
+ const debugCanvas = config.debugCanvas || null;
46
+ const debugCtx = debugCanvas
47
+ ? debugCanvas.getContext('2d')
48
+ : null;
49
+
50
+ // Two in-memory frame buffers. No <img>, no data URLs, no codec round-trips.
51
+ // ponytail: Uint8ClampedArray views over ImageData.data; we diff these directly.
52
+ let prevFrame = null; // Uint8ClampedArray, length width*height*4
53
+ let currFrame = null; // Uint8ClampedArray, length width*height*4
54
+
55
+ const video = config.videoElement || document.createElement('video');
56
+ // iOS Safari requires these attributes to autoplay a camera stream inline.
57
+ video.setAttribute('autoplay', '');
58
+ video.setAttribute('muted', '');
59
+ video.setAttribute('playsinline', '');
60
+ video.setAttribute('width', String(width));
61
+ video.setAttribute('height', String(height));
62
+
63
+ let timerId = null;
64
+ let launchedStream = null;
65
+ let stopped = false;
66
+
67
+ const stop = () => {
68
+ if (stopped) return;
69
+ stopped = true;
70
+ if (timerId !== null) {
71
+ clearInterval(timerId);
72
+ timerId = null;
73
+ }
74
+ if (launchedStream) {
75
+ launchedStream.getTracks().forEach((t) => t.stop());
76
+ launchedStream = null;
77
+ }
78
+ };
79
+
80
+ const check = () => {
81
+ if (stopped || video.readyState < 2) return; // not enough data to draw yet
82
+
83
+ // Capture current frame into currFrame.
84
+ captureCtx.drawImage(video, 0, 0, width, height);
85
+ const imageData = captureCtx.getImageData(0, 0, width, height);
86
+ currFrame = imageData.data;
87
+
88
+ // Need two frames to diff. First frame just primes prevFrame.
89
+ if (prevFrame === null) {
90
+ prevFrame = new Uint8ClampedArray(currFrame); // copy
91
+ return;
92
+ }
93
+
94
+ const data1 = prevFrame;
95
+ const data2 = currFrame;
96
+ let changedPixels = 0;
97
+
98
+ // Diff loop. Stepping by 4 over RGBA bytes.
99
+ // ponytail: plain for-loop over a typed array is the fastest portable option;
100
+ // no SIMD/WebAssembly — keeps the zero-dep, no-build promise. Ceiling: 320*240=76.8k px per tick,
101
+ // trivial at 1Hz. Upgrade path: Web Worker if interval drops below ~100ms.
102
+ for (let i = 0; i < data2.length; i += 4) {
103
+ const rDiff = Math.abs(data1[i] - data2[i]);
104
+ const gDiff = Math.abs(data1[i + 1] - data2[i + 1]);
105
+ const bDiff = Math.abs(data1[i + 2] - data2[i + 2]);
106
+ if ((rDiff + gDiff + bDiff) / 3 > pixelThreshold) {
107
+ changedPixels++;
108
+ }
109
+ }
110
+
111
+ // Optional debug visualization: paint changed pixels red, others black.
112
+ if (debugCtx) {
113
+ const diffImageData = debugCtx.createImageData(width, height);
114
+ const diffData = diffImageData.data;
115
+ for (let i = 0; i < data2.length; i += 4) {
116
+ const rDiff = Math.abs(data1[i] - data2[i]);
117
+ const gDiff = Math.abs(data1[i + 1] - data2[i + 1]);
118
+ const bDiff = Math.abs(data1[i + 2] - data2[i + 2]);
119
+ const changed = (rDiff + gDiff + bDiff) / 3 > pixelThreshold;
120
+ diffData[i] = changed ? 255 : 0;
121
+ diffData[i + 1] = 0;
122
+ diffData[i + 2] = 0;
123
+ diffData[i + 3] = 255;
124
+ }
125
+ debugCtx.putImageData(diffImageData, 0, 0);
126
+ }
127
+
128
+ // Roll currFrame into prevFrame for the next tick (copy, since currFrame is a view
129
+ // into a reused ImageData buffer that getImageData may overwrite).
130
+ prevFrame.set(currFrame);
131
+
132
+ if (changedPixels > changedPixelsRequired) {
133
+ // Hand the caller a PNG snapshot of the frame that triggered.
134
+ // This is the only data-URL encode in the whole pipeline, and only fires on motion.
135
+ onMovement(captureCanvas.toDataURL('image/png'));
136
+ }
137
+ };
138
+
139
+ const startPolling = () => {
140
+ if (timerId !== null) return;
141
+ timerId = setInterval(check, interval);
142
+ };
143
+
144
+ if (config.videoElement && config.videoElement.srcObject) {
145
+ // Caller provided an already-streaming <video>. Just poll.
146
+ startPolling();
147
+ } else {
148
+ // Launch the webcam ourselves. Side effect lives here, not in the diff loop.
149
+ navigator.mediaDevices
150
+ .getUserMedia({ video: true, audio: false })
151
+ .then((stream) => {
152
+ if (stopped) {
153
+ // User called stop() before the camera permission resolved. Clean up.
154
+ stream.getTracks().forEach((t) => t.stop());
155
+ return;
156
+ }
157
+ launchedStream = stream;
158
+ video.srcObject = stream;
159
+ video.play();
160
+ startPolling();
161
+ })
162
+ .catch((err) => {
163
+ stop();
164
+ throw err;
165
+ });
166
+ }
167
+
168
+ return stop;
169
+ }
170
+
171
+ export default detectMovement;
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "simple-movement-detector",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "description": "A zero-dependency browser library that calls your callback when the webcam detects movement",
6
+ "author": "Mikhail Gorbunov <toplenboren@gmail.com>",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./index.js",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "types": "index.d.ts",
15
+ "files": [
16
+ "index.js",
17
+ "index.d.ts",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "release": "clean-pkg-json && npm publish"
23
+ },
24
+ "keywords": [
25
+ "motion-detection",
26
+ "movement",
27
+ "webcam",
28
+ "camera",
29
+ "browser",
30
+ "zero-dependency",
31
+ "getUserMedia"
32
+ ],
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/toplenboren/simple-movement-detector.git"
37
+ },
38
+ "devDependencies": {
39
+ "clean-pkg-json": "^1.2.1"
40
+ }
41
+ }