Spaces:
Running
Running
| // v5 Phase 1 — camera lifecycle + frame capture. | |
| // No gates yet; that's Phase 2-4. This module owns the MediaStream and | |
| // exposes start/stop/captureFrame so app.js can wire UI events to it. | |
| // | |
| // Loaded as a non-module IIFE attached to window so it can coexist with | |
| // the existing app.js (which is also non-module). Convert both to ES | |
| // modules later if v5 adds a bundler. | |
| window.CapturePreview = (function () { | |
| let activeStream = null; | |
| let activeVideo = null; | |
| function isSupported() { | |
| // getUserMedia requires a secure context (HTTPS or localhost). On HTTP | |
| // LAN IPs, Chrome leaves navigator.mediaDevices undefined; Safari | |
| // exposes the namespace but getUserMedia() rejects. Check secure | |
| // context up-front so the "Use Camera" button stays hidden in | |
| // insecure-test setups instead of opening a doomed flow. | |
| return !!( | |
| window.isSecureContext && | |
| navigator.mediaDevices && | |
| typeof navigator.mediaDevices.getUserMedia === "function" | |
| ); | |
| } | |
| async function start(videoEl) { | |
| if (!window.isSecureContext) { | |
| throw new Error( | |
| "Camera requires HTTPS. Open the deployed (Hugging Face) URL, or set up HTTPS locally — http://<LAN-IP> won't work." | |
| ); | |
| } | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| throw new Error("This browser does not expose getUserMedia."); | |
| } | |
| stop(); | |
| // facingMode 'environment' = rear camera. 'ideal' (not 'exact') so | |
| // desktop laptops without a rear camera still open the front cam | |
| // rather than rejecting outright. | |
| // | |
| // Width/height are also `ideal` hints — the UA picks the closest | |
| // mode the camera actually supports. We ask for 4K because the | |
| // kol_success training set is ~3024×4032 native iPhone captures and | |
| // sub-1080 frames noticeably soften the finger boundary. Modern | |
| // iPhones and high-end Android phones honor 3840×2160; lesser | |
| // hardware quietly negotiates down (typically to 1920×1080), which | |
| // is still our previous baseline. No outright rejection. | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| facingMode: { ideal: "environment" }, | |
| width: { ideal: 3840 }, | |
| height: { ideal: 2160 }, | |
| }, | |
| audio: false, | |
| }); | |
| videoEl.srcObject = stream; | |
| // iOS Safari needs muted + playsinline (set as HTML attrs) to autoplay. | |
| await videoEl.play(); | |
| activeStream = stream; | |
| activeVideo = videoEl; | |
| return stream; | |
| } | |
| function stop() { | |
| if (activeStream) { | |
| activeStream.getTracks().forEach((t) => t.stop()); | |
| activeStream = null; | |
| } | |
| if (activeVideo) { | |
| activeVideo.srcObject = null; | |
| activeVideo = null; | |
| } | |
| } | |
| function getActiveVideoTrack() { | |
| if (!activeStream) return null; | |
| const tracks = activeStream.getVideoTracks(); | |
| return tracks.length ? tracks[0] : null; | |
| } | |
| // Torch (camera flash) support is Chromium-on-Android only — iOS | |
| // Safari/WebKit does not expose `torch` in MediaTrackCapabilities, and | |
| // most front cameras lack an LED even on Android. Callers must hide | |
| // the flash UI when this returns false. | |
| function isTorchSupported() { | |
| const track = getActiveVideoTrack(); | |
| if (!track || typeof track.getCapabilities !== "function") return false; | |
| let caps; | |
| try { | |
| caps = track.getCapabilities(); | |
| } catch (err) { | |
| return false; | |
| } | |
| return !!(caps && caps.torch); | |
| } | |
| async function setTorch(on) { | |
| const track = getActiveVideoTrack(); | |
| if (!track) throw new Error("No active video track"); | |
| await track.applyConstraints({ advanced: [{ torch: !!on }] }); | |
| } | |
| // Draw the current video frame to an offscreen canvas at native sensor | |
| // resolution and encode as a JPEG Blob suitable for FormData submission. | |
| // The server's existing /api/measure path accepts this with no changes. | |
| async function captureFrame(videoEl, mimeType, quality) { | |
| const w = videoEl.videoWidth; | |
| const h = videoEl.videoHeight; | |
| if (!w || !h) { | |
| throw new Error("Video stream has no dimensions yet"); | |
| } | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = w; | |
| canvas.height = h; | |
| const ctx = canvas.getContext("2d"); | |
| ctx.drawImage(videoEl, 0, 0, w, h); | |
| return new Promise((resolve, reject) => { | |
| canvas.toBlob( | |
| (blob) => | |
| blob ? resolve(blob) : reject(new Error("toBlob returned null")), | |
| mimeType || "image/jpeg", | |
| typeof quality === "number" ? quality : 0.92, | |
| ); | |
| }); | |
| } | |
| return { isSupported, start, stop, captureFrame, isTorchSupported, setTorch }; | |
| })(); | |