Ready
Network activity
0 requests
Connected - models run on-device
No recent network activity
0 groups0 total requests
Browse blocks
Vision
Computer-vision tools that run entirely in your browser. Detect objects in a photo and track hands, pose, face, and gestures live. Models download only when you start a block.
Object Detector
Find and label objects in a photo or webcam still with colored boxes, plus a live face-tracking camera mode.
Install this block
npx shadcn@latest add @localmode/ui/blocks/vision/object-detector'use client';/** * @file object-detector.tsx * @description Vision — Object Detector: one-shot DETR (Xenova/detr-resnet-50) object detection on upload/sample/webcam-still plus a live BlazeFace webcam face loop, fully on-device. */import { useEffect, useRef, useState } from 'react';import { Camera, RotateCcw, X } from 'lucide-react';import { useDetectObjects, useModelLoad, type UseModelLoadReturn } from '@localmode/react';import { detectFace, type FaceDetectionResultItem, type ObjectDetectionModel,} from '@localmode/core';import { transformers } from '@localmode/transformers';import { mediapipe } from '@localmode/mediapipe';import { VideoCanvas, type VideoCanvasHandle } from '@/components/video-canvas';import { BoundingBoxOverlay, DetectionLabelLegend,} from '@/components/bounding-box-overlay';import { ImageProcessingOverlay } from '@/components/image-processing-overlay';import { MediaDropzone } from '@/components/media-dropzone';import { ScoredResultBarList } from '@/components/scored-result-bar-list';import { CapabilityGate } from '@/components/capability-gate';import { readFileAsDataUrl } from '@/lib/browser-utils';import { useWebcam } from '@/hooks/use-webcam';const DETR_MODEL_ID = 'Xenova/detr-resnet-50';const SAMPLE_SRC = '/test-assets/portrait.jpg';const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];const FACE_DETECT_INTERVAL_MS = 1500;let faceModel: ReturnType<typeof mediapipe.faceDetector> | null = null;const getFaceModel = () => (faceModel ??= mediapipe.faceDetector());interface SubjectImage { src: string; width: number; height: number; origin: 'sample' | 'upload' | 'webcam'; name: string;}interface CapturedStill { src: string; width: number; height: number;}function getImageDimensions(src: string) { return new Promise<{ width: number; height: number }>((resolve, reject) => { const img = new Image(); img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); img.onerror = () => reject(new Error('Failed to load the image.')); img.src = src; });}export function ObjectDetectorBlock() { const detr = useModelLoad<ObjectDetectionModel>({ key: DETR_MODEL_ID, create: (onProgress) => transformers.objectDetector(DETR_MODEL_ID, { onProgress }), }); if (!detr.model) return null; return <DetectSurface detr={detr} detrModel={detr.model} />;}function DetectSurface({ detr, detrModel,}: { detr: UseModelLoadReturn<ObjectDetectionModel>; detrModel: ObjectDetectionModel;}) { const [subject, setSubject] = useState<SubjectImage | null>(null); const [faces, setFaces] = useState<FaceDetectionResultItem[] | null>(null); const [still, setStill] = useState<CapturedStill | null>(null); const [localError, setLocalError] = useState<string | null>(null); const webcam = useWebcam({ width: 640, height: 480 }); const videoCanvasRef = useRef<VideoCanvasHandle>(null); const detectBusyRef = useRef(false); const objects = useDetectObjects({ model: detrModel }); const startCamera = async () => { if (webcam.isActive) return; setLocalError(null); await webcam.start(); }; useEffect(() => { const stream = webcam.stream; if (!stream) return; const detectOnFrame = async () => { if (detectBusyRef.current) return; const video = videoCanvasRef.current?.video; const overlay = videoCanvasRef.current?.canvas; if (!video || !overlay || video.videoWidth === 0) return; detectBusyRef.current = true; try { const frame = document.createElement('canvas'); frame.width = video.videoWidth; frame.height = video.videoHeight; const frameCtx = frame.getContext('2d'); if (!frameCtx) return; frameCtx.drawImage(video, 0, 0); const imageData = frameCtx.getImageData(0, 0, frame.width, frame.height); const result = await detectFace({ model: getFaceModel(), image: imageData }); setFaces(result.faces); setStill({ src: frame.toDataURL('image/jpeg', 0.85), width: frame.width, height: frame.height, }); const ctx = overlay.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, overlay.width, overlay.height); ctx.lineWidth = 3; ctx.strokeStyle = '#10b981'; ctx.fillStyle = '#10b981'; ctx.font = '14px sans-serif'; for (const face of result.faces) { ctx.strokeRect(face.box.x, face.box.y, face.box.width, face.box.height); ctx.fillText( `face ${(face.score * 100).toFixed(0)}%`, face.box.x, Math.max(14, face.box.y - 4), ); for (const kp of face.keypoints) { ctx.beginPath(); ctx.arc(kp.x * overlay.width, kp.y * overlay.height, 3, 0, Math.PI * 2); ctx.fill(); } } } } catch (err) { setLocalError(err instanceof Error ? err.message : String(err)); } finally { detectBusyRef.current = false; } }; const id = window.setInterval(() => void detectOnFrame(), FACE_DETECT_INTERVAL_MS); void detectOnFrame(); return () => window.clearInterval(id); }, [webcam.stream]); const detectOn = async (next: SubjectImage) => { setLocalError(null); setSubject(next); await objects.execute(next.src); }; const detectObjects = async () => { setLocalError(null); try { if (subject) { await objects.execute(subject.src); return; } const url = new URL(SAMPLE_SRC, window.location.origin).toString(); const dims = await getImageDimensions(url); await detectOn({ ...dims, src: url, origin: 'sample', name: 'sample image' }); } catch (err) { setLocalError(err instanceof Error ? err.message : String(err)); } }; const handleFiles = async (files: File[]) => { const file = files[0]; if (!file) return; setLocalError(null); try { const dataUrl = await readFileAsDataUrl(file); const dims = await getImageDimensions(dataUrl); await detectOn({ ...dims, src: dataUrl, origin: 'upload', name: file.name }); } catch (err) { setLocalError(err instanceof Error ? err.message : String(err)); } }; const captureStill = async () => { const video = videoCanvasRef.current?.video; if (!video || video.videoWidth === 0) return; setLocalError(null); const frame = document.createElement('canvas'); frame.width = video.videoWidth; frame.height = video.videoHeight; frame.getContext('2d')?.drawImage(video, 0, 0); await detectOn({ src: frame.toDataURL('image/jpeg', 0.9), width: frame.width, height: frame.height, origin: 'webcam', name: 'webcam capture', }); }; const clear = () => { setSubject(null); setLocalError(null); objects.reset(); }; const detections = objects.data?.objects ?? []; const objectsError = objects.error?.message ?? null; const errorText = localError ?? webcam.error?.message ?? objectsError; const detrDownloading = objects.isLoading && detr.progress > 0 && detr.progress < 1; const statusText = objects.isLoading ? 'detecting objects' : errorText ? 'error' : webcam.isActive ? faces ? `camera on - ${faces.length} face(s)` : 'camera on - detecting faces' : objects.data ? 'objects detected' : 'idle'; return ( <div className="flex flex-col gap-4 p-4"> {} <p role="status" aria-live="polite" aria-label="Detector status" className="text-xs text-muted-foreground"> <span className="font-medium text-foreground">Status:</span> {statusText} </p> {errorText && ( <div role="alert" className="flex flex-wrap items-center gap-2"> <p className="text-xs text-destructive">{errorText}</p> <button type="button" onClick={() => { webcam.clearError(); objects.reset(); if (webcam.error) void startCamera(); else void detectObjects(); }} className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <RotateCcw className="h-3 w-3" aria-hidden /> Retry </button> </div> )} {} <section aria-labelledby="od-detect-heading" className="flex flex-col gap-3 rounded-lg border border-border p-4"> <h2 id="od-detect-heading" className="sr-only"> Object detection </h2> <div className="flex flex-wrap items-center gap-3"> <button type="button" onClick={() => void detectObjects()} disabled={objects.isLoading} className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" > {objects.isLoading ? 'Detecting…' : subject ? 'Detect objects' : 'Detect objects (sample image)'} </button> <span className="text-sm"> Objects found: <span className="tabular-nums">{objects.data ? detections.length : '-'}</span> </span> {subject && ( <button type="button" onClick={clear} disabled={objects.isLoading} className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <X className="h-3.5 w-3.5" aria-hidden /> Clear </button> )} </div> {!subject && ( <div className="max-w-xl"> <MediaDropzone accept={ACCEPTED_IMAGE_TYPES} multiple={false} processing={objects.isLoading} processingLabel="Detecting objects…" title="Drop an image to detect objects" subtitle="or click to browse: PNG, JPEG, WebP, GIF" onFiles={(files) => void handleFiles(files)} onReject={(rejections) => setLocalError(rejections[0]?.reason ?? 'That file type is not supported.') } /> </div> )} {subject && ( <div className="flex flex-col gap-3"> <div role="group" aria-label="Detection subject" data-origin={subject.origin} className="relative max-w-xl" > {} <img src={subject.src} alt={`Detection subject (${subject.name})`} className="w-full rounded-md" /> <BoundingBoxOverlay detections={detections} naturalWidth={subject.width} naturalHeight={subject.height} /> <ImageProcessingOverlay processing={objects.isLoading} variant="scan" status={ detrDownloading ? `Downloading model… ${(detr.progress * 100).toFixed(0)}%` : 'Detecting objects…' } detail={DETR_MODEL_ID} onCancel={objects.cancel} /> </div> {objects.data && detections.length > 0 && ( <div role="group" aria-label="Detected object labels"> <DetectionLabelLegend labels={detections.map((d) => d.label)} /> </div> )} {objects.data && ( <div role="group" aria-label="Detection results" className="max-w-xl"> <ScoredResultBarList results={detections.map((d) => ({ label: d.label, score: d.score }))} emptyState="No objects detected" /> </div> )} </div> )} </section> {} <CapabilityGate requires="wasm"> <CapabilityGate requires="camera"> <section aria-labelledby="od-camera-heading" className="flex flex-col gap-3 rounded-lg border border-border p-4"> <h2 id="od-camera-heading" className="sr-only"> Live webcam face tracking </h2> <div className="flex flex-wrap items-center gap-3"> <button type="button" onClick={() => void startCamera()} disabled={webcam.isActive} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <Camera className="h-3.5 w-3.5" aria-hidden /> {webcam.isActive ? 'Camera running' : 'Start camera'} </button> <span className="text-sm"> Faces detected: <span className="tabular-nums">{faces ? faces.length : '-'}</span> </span> {webcam.isActive && ( <button type="button" onClick={() => void captureStill()} disabled={objects.isLoading} className="inline-flex h-8 items-center rounded-md border border-border px-3 text-sm font-medium hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Capture still → detect objects </button> )} </div> <div className="max-w-xl"> <VideoCanvas ref={videoCanvasRef} stream={webcam.stream} mirrored={false} hideFps /> </div> {still && faces && ( <div> <p className="mb-1 text-xs text-muted-foreground"> Captured still + BoundingBoxOverlay (same detections) </p> <div className="relative w-80"> {} <img src={still.src} alt="Captured webcam still" className="w-full rounded-md" /> <BoundingBoxOverlay detections={faces.map((f) => ({ label: 'face', score: f.score, box: f.box, }))} naturalWidth={still.width} naturalHeight={still.height} /> </div> </div> )} </section> </CapabilityGate> </CapabilityGate> </div> );}Live Tracker
Track hands, full-body pose, a detailed face mesh, and gestures live through your webcam.
Install this block
npx shadcn@latest add @localmode/ui/blocks/vision/live-trackerhands tracker selected
Checking WebAssembly support…
'use client';/** * @file live-tracker.tsx * @description Vision — Live Tracker: one block, a 4-mode picker (Hands 21-pt / Pose 33-pt / Face 478-pt mesh + blendshapes / Gestures) over real-time MediaPipe streaming trackers on live webcam video. * @constraint Exactly one tracker/WASM task alive at a time — each sub-mode is exclusively mounted (React key per mode) and useStreamingTracker.close()s the previous on unmount, sidestepping the MediaPipe audio↔vision WASM concurrency conflict (mediapipe#4737). */import { useRef, useState, type ReactNode } from 'react';import { RotateCcw, Video, VideoOff } from 'lucide-react';import { useStreamingTracker } from '@localmode/react';import { FACE_CONNECTIONS, HAND_CONNECTIONS, POSE_CONNECTIONS, type FaceLandmarkResultItem, type GestureResultItem, type HandLandmarkResultItem, type PoseLandmarkResultItem,} from '@localmode/core';import { createFaceTracker, createGestureTracker, createHandTracker, createPoseTracker, type TrackerInstance,} from '@localmode/mediapipe';import { VideoCanvas, type VideoCanvasHandle } from '@/components/video-canvas';import { CapabilityGate } from '@/components/capability-gate';import { ConfidenceScoreBadge } from '@/components/confidence-score-badge';import { ScoredResultBarList } from '@/components/scored-result-bar-list';import { SegmentedModePicker } from '@/components/segmented-mode-picker';import { useWebcam } from '@/hooks/use-webcam';interface DrawableLandmark { x: number; y: number;}const FINGER_COLORS = { palm: '#e5e7eb', thumb: '#ef4444', index: '#f59e0b', middle: '#22c55e', ring: '#3b82f6', pinky: '#a855f7',} as const;const FINGER_RANGES: ReadonlyArray<{ color: string; indices: readonly number[] }> = [ { color: FINGER_COLORS.palm, indices: [0] }, { color: FINGER_COLORS.thumb, indices: [1, 2, 3, 4] }, { color: FINGER_COLORS.index, indices: [5, 6, 7, 8] }, { color: FINGER_COLORS.middle, indices: [9, 10, 11, 12] }, { color: FINGER_COLORS.ring, indices: [13, 14, 15, 16] }, { color: FINGER_COLORS.pinky, indices: [17, 18, 19, 20] },];function clearCanvas(ctx: CanvasRenderingContext2D): void { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);}function drawConnections( ctx: CanvasRenderingContext2D, landmarks: readonly DrawableLandmark[], connections: ReadonlyArray<readonly [number, number]>, color: string, lineWidth = 2,): void { const { width, height } = ctx.canvas; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.beginPath(); for (const [from, to] of connections) { const a = landmarks[from]; const b = landmarks[to]; if (!a || !b) continue; ctx.moveTo(a.x * width, a.y * height); ctx.lineTo(b.x * width, b.y * height); } ctx.stroke();}function drawPoints( ctx: CanvasRenderingContext2D, landmarks: readonly DrawableLandmark[], color: string, radius = 4,): void { const { width, height } = ctx.canvas; ctx.fillStyle = color; for (const landmark of landmarks) { ctx.beginPath(); ctx.arc(landmark.x * width, landmark.y * height, radius, 0, Math.PI * 2); ctx.fill(); }}function drawHand( ctx: CanvasRenderingContext2D, landmarks: readonly DrawableLandmark[],): void { drawConnections(ctx, landmarks, HAND_CONNECTIONS, '#ffffff'); for (const { color, indices } of FINGER_RANGES) { drawPoints( ctx, indices.map((i) => landmarks[i]).filter((l): l is DrawableLandmark => l != null), color, ); }}type TrackMode = 'hands' | 'pose' | 'face' | 'gestures';const MODES: Array<{ id: TrackMode; label: string }> = [ { id: 'hands', label: 'Hands' }, { id: 'pose', label: 'Pose' }, { id: 'face', label: 'Face' }, { id: 'gestures', label: 'Gestures' },];const GESTURE_LABELS: Record<string, string> = { None: 'No gesture', Closed_Fist: 'Closed Fist', Open_Palm: 'Open Palm', Pointing_Up: 'Pointing Up', Thumb_Down: 'Thumbs Down', Thumb_Up: 'Thumbs Up', Victory: 'Victory', ILoveYou: 'I Love You',};const POSE_COLOR = '#22d3ee';const FACE_COLOR = '#f472b6';export function LiveTrackerBlock() { const [mode, setMode] = useState<TrackMode>('hands'); const [showBlendshapes, setShowBlendshapes] = useState(true); return ( <div className="flex flex-col gap-3 p-4"> {} <p role="status" aria-live="polite" className="sr-only"> {mode} tracker selected </p> <SegmentedModePicker<TrackMode> items={MODES} selectedId={mode} onSelect={setMode} aria-label="Streaming tracker" /> <CapabilityGate requires="wasm"> <CapabilityGate requires="camera"> {} {mode === 'hands' && ( <TrackerSurface<HandLandmarkResultItem> key="hands" create={({ video, onResults, onError }) => createHandTracker({ video, onResults, onError }) } draw={(ctx, hands) => { for (const hand of hands) drawHand(ctx, hand.landmarks); }} landmarkCount={(hands) => hands[0]?.landmarks.length ?? 0} idleHint="Start the camera and hold up a hand: 21 landmarks per hand, up to 2 hands." panel={(hands) => ( <div className="flex flex-col gap-2"> <p className="text-sm font-medium">Detected hands</p> {hands.length === 0 ? ( <p className="text-sm text-muted-foreground">No hands detected yet.</p> ) : ( hands.map((hand, i) => ( <div key={i} className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2" > <span className="text-sm">{hand.handedness} hand</span> <ConfidenceScoreBadge score={hand.score} /> </div> )) )} </div> )} /> )} {mode === 'pose' && ( <TrackerSurface<PoseLandmarkResultItem> key="pose" create={({ video, onResults, onError }) => createPoseTracker({ video, onResults, onError }) } draw={(ctx, poses) => { for (const pose of poses) { drawConnections(ctx, pose.landmarks, POSE_CONNECTIONS, POSE_COLOR); drawPoints(ctx, pose.landmarks, '#ffffff', 3); } }} landmarkCount={(poses) => poses[0]?.landmarks.length ?? 0} idleHint="Start the camera and step back until your upper body is in frame." panel={(poses) => ( <div className="flex flex-col gap-2"> <p className="text-sm font-medium">Pose tracking</p> <p className="text-sm text-muted-foreground"> {poses.length > 0 ? `Tracking ${poses.length} person(s): 33 body landmarks each.` : 'No pose detected yet, stand back so more of your body is visible.'} </p> </div> )} /> )} {mode === 'face' && ( <TrackerSurface<FaceLandmarkResultItem> key="face" create={({ video, onResults, onError }) => createFaceTracker({ video, onResults, onError, outputBlendshapes: true }) } draw={(ctx, faces) => { for (const face of faces) { drawConnections(ctx, face.landmarks, FACE_CONNECTIONS, FACE_COLOR); drawPoints(ctx, face.landmarks, 'rgba(255,255,255,0.5)', 1); } }} landmarkCount={(faces) => faces[0]?.landmarks.length ?? 0} idleHint="Start the camera and face it: a 478-point mesh with optional expression blendshapes." panel={(faces) => { const blendshapes = faces[0]?.blendshapes ?? []; return ( <div className="flex flex-col gap-2"> <label className="flex items-center gap-2 text-sm font-medium"> <input type="checkbox" checked={showBlendshapes} onChange={(e) => setShowBlendshapes(e.target.checked)} className="h-4 w-4 accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> Show expression blendshapes </label> {showBlendshapes && ( <div role="group" aria-label="Expression blendshapes"> <ScoredResultBarList results={blendshapes.map((b) => ({ label: b.categoryName, score: b.score, }))} limit={8} emptyState="No face in view yet." /> </div> )} </div> ); }} /> )} {mode === 'gestures' && ( <TrackerSurface<GestureResultItem> key="gestures" create={({ video, onResults, onError }) => createGestureTracker({ video, onResults, onError }) } draw={(ctx, gestures) => { for (const gesture of gestures) drawHand(ctx, gesture.landmarks); }} landmarkCount={(gestures) => gestures[0]?.landmarks.length ?? 0} idleHint="Start the camera and make a gesture: thumbs up, victory, open palm, fist…" badge={(gestures) => { const top = topGesture(gestures); return ( <span role="status" aria-label="Recognized gesture" data-gesture={top?.gesture ?? ''} className="rounded-md bg-black/70 px-2 py-1 text-xs font-medium text-white" > {top ? (GESTURE_LABELS[top.gesture] ?? top.gesture) : 'No gesture yet'} </span> ); }} panel={(gestures) => { const top = topGesture(gestures); return ( <div className="flex flex-col gap-2"> <p className="text-sm font-medium">Recognized gesture</p> {top ? ( <div className="flex flex-col gap-1 rounded-md border border-border px-3 py-2"> <span className="text-xl font-semibold"> {GESTURE_LABELS[top.gesture] ?? top.gesture} </span> <span className="text-sm text-muted-foreground"> {(top.score * 100).toFixed(1)}% confidence · {top.handedness} hand </span> </div> ) : ( <p className="text-sm text-muted-foreground"> Make a gesture: thumbs up, victory, open palm, fist… </p> )} </div> ); }} /> )} </CapabilityGate> </CapabilityGate> </div> );}function topGesture(gestures: GestureResultItem[]) { return gestures.find((g) => g.gesture !== 'None') ?? gestures[0] ?? null;}interface TrackerCreateContext<T> { video: HTMLVideoElement; onResults: (results: T[], timestampMs: number) => void; onError: (error: Error) => void;}interface TrackerSurfaceProps<T> { create: (ctx: TrackerCreateContext<T>) => TrackerInstance; draw: (ctx: CanvasRenderingContext2D, results: T[]) => void; landmarkCount: (results: T[]) => number; panel: (results: T[]) => ReactNode; badge?: (results: T[]) => ReactNode; idleHint: string;}function TrackerSurface<T>({ create, draw, landmarkCount, panel, badge, idleHint,}: TrackerSurfaceProps<T>) { const videoCanvasRef = useRef<VideoCanvasHandle>(null); const webcam = useWebcam({ width: 1280, height: 720 }); const tracker = useStreamingTracker<T[]>({ video: () => videoCanvasRef.current?.video ?? null, create: (ctx) => create(ctx), onResults: (results) => { const canvas = videoCanvasRef.current?.canvas; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx) return; clearCanvas(ctx); draw(ctx, results); }, }); const running = tracker.status === 'running'; const starting = tracker.status === 'starting'; const results = tracker.results ?? []; const toggle = async () => { if (webcam.isActive || running || starting) { tracker.stop(); webcam.stop(); const ctx = videoCanvasRef.current?.canvas?.getContext('2d'); if (ctx) clearCanvas(ctx); return; } webcam.clearError(); const stream = await webcam.start(); if (!stream) return; await tracker.start(); }; const errorText = webcam.error?.message ?? tracker.error?.message ?? null; const statusValue = errorText ? 'error' : starting ? 'loading' : running ? tracker.results ? 'running' : 'waiting' : 'idle'; return ( <div className="grid gap-3 lg:grid-cols-[2fr_1fr]"> <div className="flex flex-col gap-2"> <div className="flex flex-wrap items-center gap-3"> <button type="button" onClick={() => void toggle()} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" > {webcam.isActive ? ( <VideoOff className="h-3.5 w-3.5" aria-hidden /> ) : ( <Video className="h-3.5 w-3.5" aria-hidden /> )} {webcam.isActive ? 'Stop camera' : 'Start camera'} </button> <p role="status" aria-live="polite" aria-label="Tracker status" data-status={statusValue} className="text-xs text-muted-foreground" > {errorText ? 'error' : starting ? 'loading tracker model…' : running ? tracker.results ? 'tracking' : 'waiting for first results…' : 'idle'} </p> <span role="status" aria-label="Tracker frames per second" data-fps={tracker.fps} className="text-xs tabular-nums text-muted-foreground" > {running ? `${tracker.fps} fps` : ''} </span> <span role="status" aria-label="Tracked item count" data-count={results.length} className="sr-only"> {results.length} </span> <span role="status" aria-label="Landmark count" data-points={landmarkCount(results)} className="sr-only"> {landmarkCount(results)} </span> </div> {errorText && ( <div role="alert" className="flex flex-wrap items-center gap-2"> <p className="text-xs text-destructive">{errorText}</p> <button type="button" onClick={() => void toggle()} className="inline-flex h-6 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <RotateCcw className="h-3 w-3" aria-hidden /> Retry </button> </div> )} <VideoCanvas ref={videoCanvasRef} stream={webcam.stream} fps={running ? tracker.fps : undefined} > {badge && webcam.isActive ? badge(results) : null} </VideoCanvas> {!webcam.isActive && <p className="text-xs text-muted-foreground">{idleHint}</p>} {starting && ( <p className="text-xs text-muted-foreground"> Loading the tracker model… it downloads once, then runs fully on-device. </p> )} </div> <div role="group" aria-label="Tracker output" className="rounded-lg border border-border p-3"> {panel(results)} </div> </div> );}