Skip to content

Commit 9e88906

Browse files
Partha-dev01claude
andcommitted
Fix video-capture timer, preparation camera, and clap detection
Video capture: - Wall-clock timer using Date.now() instead of state-based decrement (immune to React batching/stacking causing double-speed) - Ref guard prevents double-invocation of startAssessment - Button disabled after first click Preparation (Action Challenge): - Camera feed now visible during timeout phase (was unmounted, causing lost stream on retry) - Stream re-attach via 300ms interval check (replaces every-render useEffect that caused flicker) - Frame dot counter: 8 dots matching REQUIRED_CONSECUTIVE = 8 - Status text threshold raised to 5 for "Almost there" Action detection: - Clap threshold doubled (0.2 → 0.4 * scale) — YOLO wrist keypoints are imprecise when hands overlap during clap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0429502 commit 9e88906

4 files changed

Lines changed: 48 additions & 36 deletions

File tree

app/hooks/useActionCamera.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,16 +240,20 @@ export function useActionCamera(): UseActionCameraReturn {
240240
detectingRef.current = false;
241241
}, []);
242242

243-
// Defensive: re-attach stream when video element appears in DOM.
244-
// Only runs when isActive changes (camera just started).
243+
// Defensive: periodically check if the video element lost its stream
244+
// (happens when React unmounts/remounts the <video> during phase changes).
245+
// Uses a low-frequency interval instead of running every render to avoid flicker.
245246
useEffect(() => {
246247
if (!isActive) return;
247-
const video = videoRef.current;
248-
const stream = streamRef.current;
249-
if (video && stream && !video.srcObject) {
250-
video.srcObject = stream;
251-
video.play().catch(() => {});
252-
}
248+
const check = setInterval(() => {
249+
const video = videoRef.current;
250+
const stream = streamRef.current;
251+
if (video && stream && !video.srcObject) {
252+
video.srcObject = stream;
253+
video.play().catch(() => {});
254+
}
255+
}, 300);
256+
return () => clearInterval(check);
253257
}, [isActive]);
254258

255259
// Cleanup on unmount

app/intake/preparation/page.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,8 @@ export default function PreparationPage() {
304304
{meta.label}
305305
</h2>
306306

307-
{/* Camera feed — visible during countdown, detecting, and detected */}
308-
{(actionPhase === "countdown" || actionPhase === "detecting" || actionPhase === "detected") && (
307+
{/* Camera feed — visible during all active action phases (including timeout so stream stays attached) */}
308+
{(actionPhase === "countdown" || actionPhase === "detecting" || actionPhase === "detected" || actionPhase === "timeout") && (
309309
<>
310310
{cameraActive && !cameraError ? (
311311
<div style={{
@@ -410,12 +410,12 @@ export default function PreparationPage() {
410410
</div>
411411
)}
412412

413-
{/* 5-dot frame counter */}
413+
{/* 8-dot frame counter (matches REQUIRED_CONSECUTIVE = 8) */}
414414
{actionPhase === "detecting" && (
415-
<div style={{ display: "flex", gap: 6, justifyContent: "center", marginBottom: 8 }}>
416-
{Array.from({ length: 5 }, (_, i) => (
415+
<div style={{ display: "flex", gap: 4, justifyContent: "center", marginBottom: 8 }}>
416+
{Array.from({ length: 8 }, (_, i) => (
417417
<div key={i} style={{
418-
width: 20, height: 20, borderRadius: "50%",
418+
width: 16, height: 16, borderRadius: "50%",
419419
background: i < consecutiveHits ? "var(--sage-500)" : "var(--bg-elevated)",
420420
border: "2px solid var(--sage-300)",
421421
transition: "background 0.2s",
@@ -434,7 +434,7 @@ export default function PreparationPage() {
434434
: "var(--text-muted)",
435435
}}>
436436
{!hasKeypoints ? "Step into view so we can see you!"
437-
: consecutiveHits >= 3 ? "Almost there! Keep holding..."
437+
: consecutiveHits >= 5 ? "Almost there! Keep holding..."
438438
: (actionResult?.confidence || 0) > 0.3 ? "Getting closer!"
439439
: `Looking for: ${meta.label}...`}
440440
</p>

app/intake/video-capture/page.tsx

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -143,31 +143,37 @@ export default function VideoCapturePage() {
143143
};
144144
}, []);
145145

146+
const startingRef = useRef(false);
147+
146148
const startAssessment = useCallback(async () => {
149+
// Guard: prevent double-invocation (async race, double-click, etc.)
150+
if (startingRef.current) return;
151+
startingRef.current = true;
152+
153+
// Clear any existing intervals first
154+
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
155+
if (biomarkerTimerRef.current) { clearInterval(biomarkerTimerRef.current); biomarkerTimerRef.current = null; }
156+
147157
await startCamera();
148158
setStarted(true);
149159
setTimeLeft(ASSESSMENT_SECONDS);
150160
setSamplesCollected(0);
151161

152-
// Clear any existing intervals to prevent double-speed
153-
if (timerRef.current) clearInterval(timerRef.current);
154-
if (biomarkerTimerRef.current) clearInterval(biomarkerTimerRef.current);
155-
156-
// Countdown timer
162+
// Countdown timer — use a dedicated counter ref to avoid any React batching issues
163+
const endTime = Date.now() + ASSESSMENT_SECONDS * 1000;
157164
timerRef.current = setInterval(() => {
158-
setTimeLeft((t) => {
159-
if (t <= 1) {
160-
if (timerRef.current) clearInterval(timerRef.current);
161-
if (biomarkerTimerRef.current) clearInterval(biomarkerTimerRef.current);
162-
setTaskComplete(true);
163-
if (streamRef.current) {
164-
streamRef.current.getTracks().forEach((tr) => tr.stop());
165-
}
166-
return 0;
165+
const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
166+
setTimeLeft(remaining);
167+
if (remaining <= 0) {
168+
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
169+
if (biomarkerTimerRef.current) { clearInterval(biomarkerTimerRef.current); biomarkerTimerRef.current = null; }
170+
setTaskComplete(true);
171+
startingRef.current = false;
172+
if (streamRef.current) {
173+
streamRef.current.getTracks().forEach((tr) => tr.stop());
167174
}
168-
return t - 1;
169-
});
170-
}, 1000);
175+
}
176+
}, 500); // Check every 500ms for accurate wall-clock time
171177

172178
// Periodic biomarker snapshots
173179
biomarkerTimerRef.current = setInterval(() => {
@@ -176,8 +182,9 @@ export default function VideoCapturePage() {
176182
}, [startCamera, saveBiomarkerSnapshot]);
177183

178184
const stopEarly = useCallback(() => {
179-
if (timerRef.current) clearInterval(timerRef.current);
180-
if (biomarkerTimerRef.current) clearInterval(biomarkerTimerRef.current);
185+
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
186+
if (biomarkerTimerRef.current) { clearInterval(biomarkerTimerRef.current); biomarkerTimerRef.current = null; }
187+
startingRef.current = false;
181188
// Save final snapshot
182189
saveBiomarkerSnapshot();
183190
setTaskComplete(true);
@@ -244,7 +251,7 @@ export default function VideoCapturePage() {
244251
<Link href="/intake/motor" className="btn btn-outline" style={{ minWidth: 100 }}>
245252
← Back
246253
</Link>
247-
<button className="btn btn-primary btn-full" onClick={startAssessment}>
254+
<button className="btn btn-primary btn-full" onClick={startAssessment} disabled={started}>
248255
📹 Start Video Analysis
249256
</button>
250257
</div>
@@ -338,6 +345,7 @@ export default function VideoCapturePage() {
338345
setStarted(false);
339346
setTimeLeft(ASSESSMENT_SECONDS);
340347
setCamReady(false);
348+
startingRef.current = false;
341349
}} style={{ minHeight: 44, padding: "8px 24px" }}>
342350
Try Again
343351
</button>

app/lib/actions/actionDetector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ function detectClap(
128128
if (!confOk(conf, L_WRIST, R_WRIST))
129129
return { hit: false, proximity: 0 };
130130
const d = dist(kp(kps, L_WRIST), kp(kps, R_WRIST));
131-
const threshold = 0.2 * scale;
131+
const threshold = 0.4 * scale; // Relaxed: hands near each other (YOLO wrist keypoints are imprecise during clap)
132132
return { hit: d < threshold, proximity: Math.max(0, 1 - d / threshold) };
133133
}
134134

0 commit comments

Comments
 (0)