"use strict"; /* ============================================================ LocateAnything Live V2 Smooth Camera + Async CPU Inference ============================================================ */ const state = { stream: null, running: false, connected: false, processing: false, currentJob: null, frameId: 0, query: "person", mode: "hybrid", pollTimer: null, captureTimer: null, reconnectTimer: null, captureInterval: 1200, detections: [], inferenceStarted: 0, fpsFrames: 0, fpsStarted: performance.now(), inferenceCount: 0, inferenceFpsStarted: performance.now(), }; /* ========================================================= ELEMENTS ========================================================= */ const el = { camera: document.getElementById("camera"), overlay: document.getElementById("overlay"), cameraPlaceholder: document.getElementById( "cameraPlaceholder" ), startButton: document.getElementById( "startButton" ), stopButton: document.getElementById( "stopButton" ), snapshotButton: document.getElementById( "snapshotButton" ), mobileStart: document.getElementById( "mobileStart" ), mobileSnapshot: document.getElementById( "mobileSnapshot" ), imageUpload: document.getElementById( "imageUpload" ), query: document.getElementById( "query" ), clearQuery: document.getElementById( "clearQuery" ), chips: document.querySelectorAll( ".chip" ), modes: document.querySelectorAll( ".mode-button" ), connectionDot: document.getElementById( "connectionDot" ), connectionText: document.getElementById( "connectionText" ), liveBadge: document.getElementById( "liveBadge" ), fpsBadge: document.getElementById( "fpsBadge" ), reticle: document.getElementById( "reticle" ), objectCount: document.getElementById( "objectCount" ), latency: document.getElementById( "latency" ), inferenceFps: document.getElementById( "inferenceFps" ), statusTitle: document.getElementById( "statusTitle" ), statusMessage: document.getElementById( "statusMessage" ), resultsPanel: document.getElementById( "resultsPanel" ), resultsCount: document.getElementById( "resultsCount" ), resultsList: document.getElementById( "resultsList" ), permissionModal: document.getElementById( "permissionModal" ), permissionClose: document.getElementById( "permissionClose" ), }; /* ========================================================= SAFE ELEMENT HELPERS ========================================================= */ function exists(element) { return !!element; } function text(element, value) { if (element) { element.textContent = value; } } function toggle( element, className, enabled ) { if (!element) { return; } element.classList.toggle( className, enabled ); } /* ========================================================= STATUS ========================================================= */ function setConnection( status, message ) { state.connected = status === "online"; if (el.connectionDot) { el.connectionDot.className = `connection-dot ${status}`; } text( el.connectionText, message ); } function setStatus( title, message ) { text( el.statusTitle, title ); text( el.statusMessage, message ); } function setLive( enabled ) { toggle( el.liveBadge, "hidden", !enabled ); toggle( el.fpsBadge, "hidden", !enabled ); toggle( el.reticle, "hidden", !enabled ); } function updateButtons() { if (el.startButton) { el.startButton.disabled = state.running; } if (el.stopButton) { el.stopButton.disabled = !state.running; } if (el.snapshotButton) { el.snapshotButton.disabled = !state.running; } if (el.mobileSnapshot) { el.mobileSnapshot.disabled = !state.running; } if (el.mobileStart) { el.mobileStart.textContent = state.running ? "Stop" : "Start"; } } /* ========================================================= WEBSOCKET ========================================================= */ function websocketURL() { const protocol = location.protocol === "https:" ? "wss:" : "ws:"; return ( `${protocol}//${location.host}/ws` ); } function connectSocket() { if ( state.socket && ( state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING ) ) { return; } setConnection( "offline", "Connecting" ); try { state.socket = new WebSocket( websocketURL() ); } catch (error) { console.error(error); scheduleReconnect(); return; } state.socket.onopen = () => { console.log( "LocateAnything WebSocket connected." ); setConnection( "online", "Connected" ); if (state.running) { setStatus( "Camera active", `Searching for “${state.query}”.` ); } else { setStatus( "Ready to locate", "Start the camera to begin." ); } }; state.socket.onmessage = ( event ) => { handleServerMessage( event.data ); }; state.socket.onerror = ( error ) => { console.error( "WebSocket error:", error ); setConnection( "error", "Connection error" ); }; state.socket.onclose = () => { state.connected = false; setConnection( "offline", "Disconnected" ); if (state.running) { setStatus( "Reconnecting", "Reconnecting to the server…" ); scheduleReconnect(); } }; } function scheduleReconnect() { if (state.reconnectTimer) { return; } state.reconnectTimer = setTimeout( () => { state.reconnectTimer = null; connectSocket(); }, 1500 ); } /* ========================================================= SERVER MESSAGES ========================================================= */ function handleServerMessage( raw ) { let message; try { message = JSON.parse(raw); } catch (error) { console.error( "Invalid server JSON:", raw ); return; } console.log( "LocateAnything:", message ); switch ( message.type ) { case "connected": state.connected = true; setConnection( "online", "Connected" ); break; case "job": handleJobCreated( message ); break; case "busy": handleBusy( message ); break; case "processing": setStatus( "Analyzing", `Locating “${state.query}”…` ); break; case "result": /* * Compatibility with the older backend. */ handleResult( message ); break; case "error": handleError( message ); break; case "stopped": state.processing = false; state.currentJob = null; break; case "pong": break; default: console.log( "Unknown server message:", message ); } } /* ========================================================= JOB CREATED ========================================================= */ function handleJobCreated( message ) { const jobId = message.job_id; if (!jobId) { state.processing = false; setStatus( "Job error", "The server did not return a job ID." ); return; } state.currentJob = jobId; state.processing = true; state.inferenceStarted = performance.now(); setStatus( "Analyzing", `Locating “${state.query}”…` ); /* * Start polling. * * The camera itself continues running. */ pollJob( jobId ); } /* ========================================================= POLL JOB ========================================================= */ async function pollJob( jobId ) { if ( !state.processing || state.currentJob !== jobId ) { return; } try { const response = await fetch( `/job/${encodeURIComponent( jobId )}`, { method: "GET", cache: "no-store", } ); if (!response.ok) { throw new Error( `HTTP ${response.status}` ); } const job = await response.json(); if ( job.status === "queued" ) { updateJobTimer(); scheduleJobPoll( jobId, 1500 ); return; } if ( job.status === "running" ) { updateJobTimer(); scheduleJobPoll( jobId, 2000 ); return; } if ( job.status === "completed" ) { state.processing = false; state.currentJob = null; handleResult( job ); /* * Give the camera a moment before * submitting the next frame. */ scheduleCapture( 800 ); return; } if ( job.status === "error" ) { state.processing = false; state.currentJob = null; handleError( job ); scheduleCapture( 1500 ); return; } scheduleJobPoll( jobId, 1500 ); } catch (error) { console.error( "Job polling error:", error ); /* * Don't immediately kill the job. * * The CPU inference can still be running. */ scheduleJobPoll( jobId, 3000 ); } } function scheduleJobPoll( jobId, delay ) { if (state.pollTimer) { clearTimeout( state.pollTimer ); } state.pollTimer = setTimeout( () => { state.pollTimer = null; pollJob( jobId ); }, delay ); } /* ========================================================= INFERENCE TIMER ========================================================= */ function updateJobTimer() { if ( !state.inferenceStarted ) { return; } const elapsed = performance.now() - state.inferenceStarted; const seconds = elapsed / 1000; setStatus( "Analyzing", `Locating “${state.query}”… ${seconds.toFixed(0)}s` ); } /* ========================================================= RESULT ========================================================= */ function handleResult( message ) { const detections = Array.isArray( message.detections ) ? message.detections : []; state.detections = detections; state.inferenceCount++; text( el.objectCount, String( detections.length ) ); if ( Number.isFinite( Number( message.latency ) ) ) { text( el.latency, `${( Number( message.latency ) / 1000 ).toFixed(1)}s` ); } updateInferenceFPS(); updateResults( detections ); drawOverlay(); if ( detections.length ) { setStatus( `${detections.length} ${ detections.length === 1 ? "object" : "objects" } located`, `Latest result for “${state.query}”.` ); } else { setStatus( "Nothing located", `No matching “${state.query}” was found in the latest frame.` ); } } /* ========================================================= ERROR ========================================================= */ function handleError( message ) { console.error( "LocateAnything error:", message ); setStatus( "Inference error", message.detail || message.message || "Inference failed." ); } /* ========================================================= BUSY ========================================================= */ function handleBusy( message ) { state.processing = true; setStatus( "Analyzing", message.message || "Previous inference is still running." ); } /* ========================================================= CAMERA ========================================================= */ async function startCamera() { if (state.running) { stopCamera(); return; } try { if ( !navigator.mediaDevices || !navigator.mediaDevices .getUserMedia ) { throw new Error( "Camera API is unavailable." ); } const stream = await navigator .mediaDevices .getUserMedia( { video: { facingMode: { ideal: "environment" }, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30, max: 30 } }, audio: false } ); state.stream = stream; el.camera.srcObject = stream; await el.camera.play(); state.running = true; state.processing = false; if ( el.cameraPlaceholder ) { el.cameraPlaceholder .classList .add( "hidden" ); } setLive( true ); updateButtons(); setStatus( "Camera active", `Searching for “${state.query}”.` ); connectSocket(); startCameraFPS(); resizeCanvas(); /* * Submit the first frame shortly * after the camera starts. */ scheduleCapture( 1000 ); } catch (error) { console.error( "Camera error:", error ); if ( el.permissionModal ) { el.permissionModal .classList .remove( "hidden" ); } setStatus( "Camera unavailable", error.message || "Please allow camera access." ); } } /* ========================================================= STOP ========================================================= */ function stopCamera() { state.running = false; state.processing = false; state.currentJob = null; if ( state.captureTimer ) { clearTimeout( state.captureTimer ); state.captureTimer = null; } if ( state.pollTimer ) { clearTimeout( state.pollTimer ); state.pollTimer = null; } if ( state.stream ) { state.stream .getTracks() .forEach( track => track.stop() ); state.stream = null; } if ( el.camera ) { el.camera.srcObject = null; } state.detections = []; clearOverlay(); setLive( false ); updateButtons(); if ( el.cameraPlaceholder ) { el.cameraPlaceholder .classList .remove( "hidden" ); } setStatus( "Camera stopped", "Start the camera to continue." ); if ( state.socket && state.socket.readyState === WebSocket.OPEN ) { try { state.socket.send( JSON.stringify( { type: "stop" } ) ); } catch (error) { console.warn( error ); } } } /* ========================================================= CAMERA CAPTURE ========================================================= */ function scheduleCapture( delay ) { if (!state.running) { return; } if ( state.captureTimer ) { clearTimeout( state.captureTimer ); } state.captureTimer = setTimeout( () => { state.captureTimer = null; captureFrame(); }, delay ); } function captureFrame() { if (!state.running) { return; } /* * Never submit another frame while * the previous CPU inference is running. */ if (state.processing) { return; } if ( !state.socket || state.socket.readyState !== WebSocket.OPEN ) { connectSocket(); scheduleCapture( 2000 ); return; } const video = el.camera; if ( !video.videoWidth || !video.videoHeight ) { scheduleCapture( 1000 ); return; } const canvas = document.createElement( "canvas" ); const maximum = 768; const ratio = Math.min( 1, maximum / Math.max( video.videoWidth, video.videoHeight ) ); canvas.width = Math.round( video.videoWidth * ratio ); canvas.height = Math.round( video.videoHeight * ratio ); const context = canvas.getContext( "2d", { alpha: false } ); context.drawImage( video, 0, 0, canvas.width, canvas.height ); const image = canvas.toDataURL( "image/jpeg", 0.68 ); state.frameId++; try { state.socket.send( JSON.stringify( { type: "frame", frame_id: state.frameId, image, query: state.query, mode: state.mode } ) ); /* * The server will send "job". * * Do NOT schedule another capture * here. The next frame is scheduled * after the current job completes. */ } catch (error) { console.error( "Frame send failed:", error ); scheduleCapture( 2000 ); } } /* ========================================================= SNAPSHOT ========================================================= */ function snapshot() { if ( !state.running || !el.camera.videoWidth ) { return; } const canvas = document.createElement( "canvas" ); canvas.width = el.camera.videoWidth; canvas.height = el.camera.videoHeight; const context = canvas.getContext( "2d" ); context.drawImage( el.camera, 0, 0 ); /* * Also draw the latest detections * onto the snapshot. */ drawSnapshotDetections( context, canvas.width, canvas.height ); const link = document.createElement( "a" ); link.download = `locateanything-${Date.now()}.jpg`; link.href = canvas.toDataURL( "image/jpeg", 0.92 ); link.click(); } function drawSnapshotDetections( context, width, height ) { const sourceWidth = Number( el.camera.videoWidth ); const sourceHeight = Number( el.camera.videoHeight ); if ( !sourceWidth || !sourceHeight ) { return; } const scaleX = width / sourceWidth; const scaleY = height / sourceHeight; state.detections.forEach( ( detection, index ) => { const x = detection.x1 * scaleX; const y = detection.y1 * scaleY; const w = ( detection.x2 - detection.x1 ) * scaleX; const h = ( detection.y2 - detection.y1 ) * scaleY; const color = getDetectionColor( index ); context.strokeStyle = color; context.lineWidth = 4; context.strokeRect( x, y, w, h ); context.font = "bold 20px system-ui"; const label = detection.label || "object"; const textWidth = context.measureText( label ).width; context.fillStyle = "rgba(0,0,0,.85)"; context.fillRect( x, Math.max( 0, y - 32 ), textWidth + 20, 32 ); context.fillStyle = color; context.fillText( label, x + 10, Math.max( 22, y - 10 ) ); } ); } /* ========================================================= UPLOAD ========================================================= */ function handleUpload( event ) { const file = event.target.files && event.target.files[0]; if (!file) { return; } const url = URL.createObjectURL( file ); const image = new Image(); image.onload = () => { const canvas = document.createElement( "canvas" ); const maximum = 1024; const ratio = Math.min( 1, maximum / Math.max( image.width, image.height ) ); canvas.width = Math.round( image.width * ratio ); canvas.height = Math.round( image.height * ratio ); const context = canvas.getContext( "2d" ); context.drawImage( image, 0, 0, canvas.width, canvas.height ); const data = canvas.toDataURL( "image/jpeg", 0.75 ); sendImage( data ); URL.revokeObjectURL( url ); }; image.onerror = () => { URL.revokeObjectURL( url ); setStatus( "Upload failed", "The selected image could not be opened." ); }; image.src = url; event.target.value = ""; } /* ========================================================= SEND UPLOADED IMAGE ========================================================= */ function sendImage( image ) { if ( !state.socket || state.socket.readyState !== WebSocket.OPEN ) { connectSocket(); setStatus( "Connecting", "Connecting to inference server…" ); return; } if (state.processing) { setStatus( "Busy", "The previous image is still being analyzed." ); return; } state.frameId++; try { state.socket.send( JSON.stringify( { type: "frame", frame_id: state.frameId, image, query: state.query, mode: state.mode } ) ); state.processing = true; } catch (error) { console.error( error ); } } /* ========================================================= QUERY ========================================================= */ function setQuery( value ) { value = String( value || "" ) .trim() .substring( 0, 500 ); if (!value) { value = "all objects"; } state.query = value; if (el.query) { el.query.value = value; } if (el.chips) { el.chips.forEach( chip => { chip.classList.toggle( "active", chip.dataset.query === value ); } ); } if (state.running) { setStatus( "Target updated", `Searching for “${value}”.` ); } } /* ========================================================= MODE ========================================================= */ function setMode( mode ) { mode = String( mode || "hybrid" ) .toLowerCase(); if ( ![ "hybrid", "fast", "slow", ].includes(mode) ) { mode = "hybrid"; } state.mode = mode; if (el.modes) { el.modes.forEach( button => { button.classList.toggle( "active", button.dataset.mode === mode ); } ); } } /* ========================================================= RESULTS ========================================================= */ function updateResults( detections ) { text( el.resultsCount, String( detections.length ) ); if (!detections.length) { toggle( el.resultsPanel, "hidden", true ); if (el.resultsList) { el.resultsList.innerHTML = ""; } return; } toggle( el.resultsPanel, "hidden", false ); if (!el.resultsList) { return; } el.resultsList.innerHTML = detections .slice(0, 30) .map( ( detection, index ) => { const score = Number.isFinite( Number( detection.score ) ) ? ` ${ ( Number( detection.score ) * 100 ).toFixed(0) }%` : ""; return `