forked from Premshaw23/Learnova
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfix.patch
More file actions
247 lines (231 loc) · 20.8 KB
/
Copy pathfix.patch
File metadata and controls
247 lines (231 loc) · 20.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
commit cc74012bb82f29d55f4301f7c2fa9a6617ab6795
Author: AI Agent <you@example.com>
Date: Sat May 23 07:22:26 2026 +0530
fix: resolve webcam memory leak and persistent camera activation
- Add streamRef to track active MediaStream across all code paths
- Add isMountedRef abort flag to prevent state updates after unmount
- Create reusable stopMediaStream cleanup utility
- Fix handleRetry to stop old streams before starting new ones
- Add unmount guards at every async boundary in loadModels, startVideo, runDetection
- Ensure getUserMedia streams are immediately stopped if acquired after unmount
- Add defensive handling for NotFoundError and NotReadableError
- Comprehensive useEffect cleanup stops all tracks and clears video srcObject
Closes #421
diff --git a/components/FaceRecognizer.js b/components/FaceRecognizer.js
index 7612cad..8b5976b 100644
--- a/components/FaceRecognizer.js
+++ b/components/FaceRecognizer.js
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import * as faceapi from "face-api.js";
import { Button } from "@/components/ui/button";
import Link from "next/link";
@@ -14,6 +14,14 @@ const MIN_CONFIDENCE_TO_RECORD = 60;
export default function FaceRecognizer({ authUser }) {
const videoRef = useRef(null);
const canvasRef = useRef(null);
+ // Tracks the active MediaStream so cleanup can always reach it,
+ // regardless of whether the stream was created by startVideo or handleRetry.
+ const streamRef = useRef(null);
+ // Abort flag: prevents state updates after unmount and stops async chains.
+ // This is essential because getUserMedia, model loading, and face detection
+ // are all async operations that can resolve after component unmount.
+ const isMountedRef = useRef(true);
+
const { labels: fetchedLabels, loading: labelsLoading, error } = useLabels(authUser);
const [message, setMessage] = useState("Loading models...");
@@ -28,13 +36,46 @@ export default function FaceRecognizer({ authUser }) {
// Use labels directly from MongoDB with full image URL
const labels = fetchedLabels;
+ /**
+ * Safely stops all tracks on the active media stream and releases
+ * the webcam hardware. Idempotent ΓÇö safe to call multiple times.
+ * Also clears the video element's srcObject to ensure the browser
+ * releases the camera indicator immediately.
+ */
+ const stopMediaStream = useCallback(() => {
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach((track) => {
+ track.stop();
+ });
+ streamRef.current = null;
+ }
+ if (videoRef.current) {
+ videoRef.current.srcObject = null;
+ }
+ }, []);
+
const handleRetry = async () => {
try {
- // Try accessing the camera
+ // Stop any existing stream before starting a new one to prevent
+ // stream accumulation when "Scan Again" is clicked multiple times.
+ stopMediaStream();
+
const stream = await navigator.mediaDevices.getUserMedia({ video: {} });
+
+ // Guard: if component unmounted during getUserMedia, stop the
+ // newly acquired stream immediately and bail out.
+ if (!isMountedRef.current) {
+ stream.getTracks().forEach((t) => t.stop());
+ return;
+ }
+
+ // Store in ref so cleanup can always reach this stream.
+ streamRef.current = stream;
+
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.onloadedmetadata = () => {
+ if (!isMountedRef.current) return;
videoRef.current.play();
setIsLoading(false);
runDetection();
@@ -44,12 +85,18 @@ export default function FaceRecognizer({ authUser }) {
setFinished(false);
setAttendanceState("idle");
} catch (err) {
+ // Don't update state if the component was unmounted during the await
+ if (!isMountedRef.current) return;
+
// Handle permanent denial gracefully
if (err.name === "NotAllowedError") {
setMessage(
"Camera access is blocked! To enable it:\n1. Open your browser settings.\n2. Go to 'Site Settings'.\n3. Find 'Camera' permissions.\n4. Allow access for this site."
);
setFinished(true);
+ } else if (err.name === "NotFoundError" || err.name === "NotReadableError") {
+ setMessage("No camera found or camera is in use by another application ❌");
+ setFinished(true);
} else {
setMessage("Cannot access camera ❌");
setFinished(true);
@@ -58,8 +105,11 @@ export default function FaceRecognizer({ authUser }) {
};
useEffect(() => {
- let stream;
- let detectionInterval;
+ // Mark component as mounted. This ref is checked at every async
+ // boundary to prevent state updates on unmounted components and
+ // to stop async chains (model loading, video init, detection)
+ // from continuing after the user navigates away.
+ isMountedRef.current = true;
const loadModels = async () => {
try {
@@ -68,9 +118,15 @@ export default function FaceRecognizer({ authUser }) {
faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL),
faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL),
]);
+
+ // Guard: if component unmounted during model loading, don't
+ // proceed to webcam initialization.
+ if (!isMountedRef.current) return;
+
setMessage("Models loaded ✅ Starting webcam...");
startVideo();
} catch (err) {
+ if (!isMountedRef.current) return;
console.error("Model load error:", err);
setMessage(
"Failed to load models. Please check your network connection."
@@ -82,22 +138,38 @@ export default function FaceRecognizer({ authUser }) {
const startVideo = async () => {
try {
- stream = await navigator.mediaDevices.getUserMedia({ video: {} });
+ const stream = await navigator.mediaDevices.getUserMedia({ video: {} });
+
+ // Guard: if component unmounted during getUserMedia, stop the
+ // acquired stream immediately to release the camera hardware.
+ if (!isMountedRef.current) {
+ stream.getTracks().forEach((t) => t.stop());
+ return;
+ }
+
+ // Store in ref so cleanup (and handleRetry) can always reach it.
+ streamRef.current = stream;
+
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.onloadedmetadata = () => {
+ // Guard: check again after the async metadata load event.
+ if (!isMountedRef.current) return;
videoRef.current.play();
setIsLoading(false);
setMessage("Camera active. Looking for faces...");
- runDetection(); // Start recognition loop
+ runDetection();
};
}
} catch (err) {
+ if (!isMountedRef.current) return;
console.error("Webcam error:", err);
if (err.name === "NotAllowedError") {
setMessage(
`Camera access is blocked! To enable it: \n 1. Open your browser settings.\n2. Go to 'Site Settings'.\n3. Find 'Camera' permissions.\n4. Allow access for this site.`
);
+ } else if (err.name === "NotFoundError" || err.name === "NotReadableError") {
+ setMessage("No camera found or camera is in use by another application ❌");
} else {
setMessage("Cannot access webcam ❌. Please try again.");
}
@@ -108,14 +180,22 @@ export default function FaceRecognizer({ authUser }) {
if (!labelsLoading && !error && labels.length > 0) loadModels();
+ // Comprehensive cleanup: runs on component unmount AND when
+ // dependencies change (e.g., labels reload). This ensures:
+ // 1. All media stream tracks are stopped (camera light turns off)
+ // 2. Video element is cleared
+ // 3. The isMountedRef flag prevents any in-flight async work from
+ // updating state or starting new streams after this point.
return () => {
- if (stream) stream.getTracks().forEach((track) => track.stop());
- if (videoRef.current) videoRef.current.srcObject = null;
+ isMountedRef.current = false;
+ stopMediaStream();
};
// Only run when labels have loaded
- }, [labelsLoading, error]);
+ }, [labelsLoading, error, stopMediaStream]);
const runDetection = async () => {
+ // Guard: bail early if the component has already unmounted.
+ if (!isMountedRef.current) return;
if (
!videoRef.current ||
!canvasRef.current ||
@@ -128,7 +208,11 @@ export default function FaceRecognizer({ authUser }) {
await Promise.all(
labels.map(async (student) => {
try {
+ // Check mount status before each network request to avoid
+ // unnecessary work after navigation.
+ if (!isMountedRef.current) return null;
const img = await faceapi.fetchImage(student.image); // full URL from MongoDB
+ if (!isMountedRef.current) return null;
const detection = await faceapi
.detectSingleFace(img, new faceapi.TinyFaceDetectorOptions())
.withFaceLandmarks()
@@ -147,6 +231,9 @@ export default function FaceRecognizer({ authUser }) {
)
).filter(Boolean);
+ // Guard: check mount status after the heavy async label-building work.
+ if (!isMountedRef.current) return;
+
if (!labeledFaceDescriptors.length) {
setMessage("No labeled faces found ❌");
setFinished(true);
@@ -165,11 +252,16 @@ export default function FaceRecognizer({ authUser }) {
canvas.height = displaySize.height;
faceapi.matchDimensions(canvas, displaySize);
+ // Guard: check mount status before and after the final detection pass.
+ if (!isMountedRef.current) return;
+
const detections = await faceapi
.detectAllFaces(video, new faceapi.TinyFaceDetectorOptions())
.withFaceLandmarks()
.withFaceDescriptors();
+ if (!isMountedRef.current) return;
+
const resizedDetections = faceapi.resizeResults(detections, displaySize);
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);