Spaces:
Sleeping
Sleeping
Antigravity AI Agent
UX: Remove 'Waiting for camera', icon toggle, native phone camera, fix VLM error
5ed261a | (() => { | |
| const $ = (selector) => document.querySelector(selector); | |
| let stream = null; | |
| let processing = false; | |
| let lastAutoScan = 0; | |
| let previous = null; | |
| let stableFrames = 0; | |
| let currentDocumentText = ""; | |
| const sample = document.createElement("canvas"); | |
| const context = sample.getContext("2d", { willReadFrequently: true }); | |
| sample.width = 96; | |
| sample.height = 72; | |
| function setDetails(open) { | |
| const shell = $("#scannerShell"); | |
| shell.classList.toggle("details-open", open); | |
| shell.classList.toggle("details-closed", !open); | |
| $("#scanDetails").setAttribute("aria-hidden", String(!open)); | |
| $("#detailsToggle").setAttribute("aria-expanded", String(open)); | |
| } | |
| async function start() { | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| facingMode: { ideal: "environment" }, | |
| width: { ideal: 1280 }, | |
| height: { ideal: 720 }, | |
| }, | |
| audio: false, | |
| }); | |
| $("#video").srcObject = stream; | |
| await $("#video").play(); | |
| $("#video").hidden = false; | |
| $("#documentScroller").hidden = true; | |
| $("#viewport").appendChild($("#overlay")); | |
| $("#documentInfo").hidden = true; | |
| $("#emptyState").classList.add("hidden"); | |
| $("#viewport").classList.add("active"); | |
| $("#statusPill").classList.add("live"); | |
| $("#statusPill span").textContent = "Live · private preview"; | |
| $("#captureButton").disabled = false; | |
| $("#qualityPill").hidden = false; | |
| setStatus("Camera ready", "Position the document inside the frame and hold still. Capture starts automatically when the page is clear."); | |
| requestAnimationFrame(monitor); | |
| } catch (error) { | |
| setStatus( | |
| "Camera unavailable", | |
| error.name === "NotAllowedError" | |
| ? "Camera permission was denied. Allow access and try again." | |
| : "Use HTTPS or localhost and make sure a camera is connected.", | |
| ); | |
| setDetails(true); | |
| } | |
| } | |
| function monitor() { | |
| if (!stream) return; | |
| const video = $("#video"); | |
| if (video.readyState >= 2) { | |
| context.drawImage(video, 0, 0, sample.width, sample.height); | |
| const data = context.getImageData(0, 0, sample.width, sample.height).data; | |
| let brightness = 0; | |
| let motion = 0; | |
| let edges = 0; | |
| const gray = new Uint8Array(sample.width * sample.height); | |
| for (let source = 0, target = 0; source < data.length; source += 4, target += 1) { | |
| gray[target] = data[source] * 0.299 + data[source + 1] * 0.587 + data[source + 2] * 0.114; | |
| brightness += gray[target]; | |
| if (previous) motion += Math.abs(gray[target] - previous[target]); | |
| if (target > sample.width) edges += Math.abs(gray[target] - gray[target - sample.width]); | |
| } | |
| brightness /= gray.length; | |
| motion = previous ? motion / gray.length : 99; | |
| edges /= gray.length; | |
| previous = gray; | |
| const lightOkay = brightness > 42 && brightness < 220; | |
| const sharp = edges > 5.2; | |
| const still = motion < 3.8; | |
| stableFrames = lightOkay && sharp && still ? stableFrames + 1 : 0; | |
| $("#qualityPill").textContent = !lightOkay | |
| ? brightness < 42 ? "More light needed" : "Reduce glare" | |
| : !sharp ? "Move closer / focus" | |
| : !still ? "Hold steady" | |
| : stableFrames < 12 ? "Almost stable…" : "Frame stable"; | |
| $("#qualityPill").hidden = false; | |
| if (stableFrames >= 12 && !processing && Date.now() - lastAutoScan > 8000) { | |
| lastAutoScan = Date.now(); | |
| analyze(); | |
| } | |
| } | |
| requestAnimationFrame(monitor); | |
| } | |
| async function analyze() { | |
| if (processing || !stream) return; | |
| const video = $("#video"); | |
| const canvas = $("#captureCanvas"); | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| canvas.getContext("2d").drawImage(video, 0, 0); | |
| await analyzeCanvas(canvas, "camera"); | |
| } | |
| async function analyzeCanvas(canvas, source) { | |
| if (processing) return; | |
| processing = true; | |
| stableFrames = 0; | |
| setStatus("Finding document terms", source === "upload" ? "Reading the uploaded page securely." : "Reading one clear camera frame."); | |
| showProgress("Recognizing text and matching business terminology…"); | |
| const payload = { | |
| image_base64: canvas.toDataURL("image/jpeg", 0.75), | |
| frame_width: canvas.width, | |
| frame_height: canvas.height, | |
| language_preference: $("#language").value, | |
| }; | |
| try { | |
| const response = await fetch("/analyze-frame", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Scan failed"); | |
| if (!data.ocr_available) { | |
| setStatus("Recognition unavailable", `${data.message}. Install the full requirements to enable document recognition.`); | |
| toast("The interface is ready. OCR needs the PaddleOCR dependency."); | |
| } else { | |
| renderAnalysis(data, canvas.width, canvas.height); | |
| } | |
| } catch (error) { | |
| setStatus("Couldn’t scan this page", error.message); | |
| } finally { | |
| processing = false; | |
| setDetails(true); | |
| } | |
| } | |
| async function handleUpload(event) { | |
| const file = event.target.files?.[0]; | |
| event.target.value = ""; | |
| if (!file) return; | |
| const extension = file.name.split(".").pop()?.toLowerCase(); | |
| const supported = ["jpg", "jpeg", "png", "webp", "pdf", "docx"]; | |
| if (!supported.includes(extension)) { | |
| toast("Please choose JPG, PNG, WebP, PDF, or Word DOCX."); | |
| return; | |
| } | |
| if (file.size > 15 * 1024 * 1024) { | |
| toast("Choose a document smaller than 15 MB."); | |
| return; | |
| } | |
| processing = true; | |
| setStatus("Preparing document", "Creating a private preview and finding useful business terms."); | |
| showProgress("Opening and reading the document…"); | |
| setDetails(true); | |
| try { | |
| const prepared = await prepareUpload(file); | |
| const response = await fetch("/analyze-document", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| file_base64: prepared.base64, | |
| filename: prepared.filename, | |
| language_preference: $("#language").value, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Document analysis failed"); | |
| const preview = $("#uploadedPreview"); | |
| await loadPreview(preview, data.preview_base64); | |
| $("#documentStage").appendChild($("#overlay")); | |
| $("#documentScroller").hidden = false; | |
| $("#documentScroller").scrollTo({ top: 0, left: 0, behavior: "instant" }); | |
| $("#video").hidden = true; | |
| $("#emptyState").classList.add("hidden"); | |
| $("#viewport").classList.remove("active"); | |
| $("#statusPill").classList.add("live"); | |
| $("#statusPill span").textContent = "Uploaded · not stored"; | |
| $("#qualityPill").textContent = file.name; | |
| $("#qualityPill").hidden = false; | |
| $("#captureButton").disabled = true; | |
| renderAnalysis(data, data.frame_width, data.frame_height); | |
| } catch (error) { | |
| setStatus("Couldn’t read this document", error.message); | |
| toast(error.message); | |
| } finally { | |
| processing = false; | |
| setDetails(true); | |
| } | |
| } | |
| function renderAnalysis(data, frameWidth, frameHeight) { | |
| const terms = data.detected_terms || []; | |
| $("#resultTitle").textContent = terms.length ? "Document ready" : "No useful terms found"; | |
| $("#statusMessage").textContent = terms.length | |
| ? "Tap a dot on the document to open its live definition. Drag a dot if it covers important text." | |
| : data.unknown_terms?.length | |
| ? "Text was found, but no glossary terms matched. Try a clearer document." | |
| : "Try a sharper image or a page containing customs, freight, or shipping terminology."; | |
| window.FalconOverlay.render(terms, frameWidth, frameHeight, data.ocr_items || []); | |
| currentDocumentText = (data.ocr_items || []).map((item) => item.text || "").filter(Boolean).join(" ").slice(0, 5000); | |
| $("#documentInfo").hidden = !currentDocumentText; | |
| $("#selectionHint").hidden = !(data.ocr_items || []).length; | |
| $("#vlmButton").disabled = !data.suggest_vlm; | |
| $("#vlmButton").title = data.vlm_reasons?.join(", ") || ""; | |
| } | |
| async function prepareUpload(file) { | |
| if (!file.type.startsWith("image/")) { | |
| return { base64: await readBase64(file), filename: file.name }; | |
| } | |
| const objectUrl = URL.createObjectURL(file); | |
| try { | |
| const image = await loadImage(objectUrl); | |
| const maxEdge = 1000; | |
| const scale = Math.min(1, maxEdge / Math.max(image.naturalWidth, image.naturalHeight)); | |
| if (scale === 1 && file.size < 2 * 1024 * 1024) { | |
| return { base64: await readBase64(file), filename: file.name }; | |
| } | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); | |
| canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); | |
| canvas.getContext("2d").drawImage(image, 0, 0, canvas.width, canvas.height); | |
| const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.70)); | |
| return { base64: await readBase64(blob), filename: "optimized-upload.jpg" }; | |
| } finally { | |
| URL.revokeObjectURL(objectUrl); | |
| } | |
| } | |
| function readBase64(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = () => resolve(String(reader.result).split(",", 2)[1]); | |
| reader.onerror = () => reject(new Error("The document could not be read")); | |
| reader.readAsDataURL(file); | |
| }); | |
| } | |
| function loadImage(source) { | |
| return new Promise((resolve, reject) => { | |
| const image = new Image(); | |
| image.onload = () => resolve(image); | |
| image.onerror = () => reject(new Error("The image could not be prepared")); | |
| image.src = source; | |
| }); | |
| } | |
| function showProgress(message) { | |
| $("#termsList").innerHTML = `<div class="analysis-progress"><i aria-hidden="true"></i><span>${message}</span></div>`; | |
| } | |
| function loadPreview(element, source) { | |
| return new Promise((resolve, reject) => { | |
| element.onload = resolve; | |
| element.onerror = () => reject(new Error("The document preview could not be rendered")); | |
| element.src = source; | |
| }); | |
| } | |
| async function analyzeVlm() { | |
| $("#vlmButton").disabled = true; | |
| try { | |
| const response = await fetch("/analyze-document-vlm", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ user_requested: true }), | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(data.detail || "Full document analysis failed"); | |
| } | |
| toast(data.message || "Analysis complete"); | |
| } catch (error) { | |
| toast(error.message || "Full document analysis is not available on this device."); | |
| } finally { | |
| setTimeout(() => { $("#vlmButton").disabled = false; }, 1200); | |
| } | |
| } | |
| function setStatus(title, message) { | |
| $("#resultTitle").textContent = title; | |
| $("#statusMessage").textContent = message; | |
| } | |
| function toast(message) { | |
| const element = $("#toast"); | |
| element.textContent = message; | |
| element.classList.add("show"); | |
| setTimeout(() => element.classList.remove("show"), 3500); | |
| } | |
| document.addEventListener("DOMContentLoaded", () => { | |
| window.FalconOverlay.init(); | |
| window.FalconFeedback.init(); | |
| window.FalconInsights.init(); | |
| setDetails(false); | |
| // Detect touch/mobile device | |
| const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || ('ontouchstart' in window); | |
| if (isMobile) { | |
| $("#startCamera").style.display = "none"; | |
| $("#startCameraPhone").style.display = ""; | |
| } | |
| $("#startCamera").onclick = start; | |
| // Mobile: use native camera input for photo capture | |
| $("#startCameraPhone").onclick = () => { | |
| $("#phoneCameraInput").click(); | |
| }; | |
| $("#phoneCameraInput").onchange = async (event) => { | |
| const file = event.target.files?.[0]; | |
| event.target.value = ""; | |
| if (!file) return; | |
| // Normalize to JPEG for iOS compatibility (HEIC/HEIF may not be supported server-side) | |
| let processFile = file; | |
| const mimeType = file.type.toLowerCase(); | |
| if (mimeType === "image/heic" || mimeType === "image/heif" || !file.name.match(/\.(jpg|jpeg|png|webp)$/i)) { | |
| try { | |
| const objectUrl = URL.createObjectURL(file); | |
| const img = await new Promise((res, rej) => { | |
| const image = new Image(); | |
| image.onload = () => res(image); | |
| image.onerror = rej; | |
| image.src = objectUrl; | |
| }); | |
| const cvs = document.createElement("canvas"); | |
| cvs.width = img.naturalWidth; cvs.height = img.naturalHeight; | |
| cvs.getContext("2d").drawImage(img, 0, 0); | |
| const blob = await new Promise(res => cvs.toBlob(res, "image/jpeg", 0.85)); | |
| URL.revokeObjectURL(objectUrl); | |
| processFile = new File([blob], "camera-photo.jpg", { type: "image/jpeg" }); | |
| } catch (_) { | |
| // fallback: try passing original | |
| processFile = new File([file], "camera-photo.jpg", { type: "image/jpeg" }); | |
| } | |
| } | |
| handleUpload({ target: { files: [processFile], value: "" } }); | |
| }; | |
| $("#captureButton").onclick = analyze; | |
| $("#vlmButton").onclick = analyzeVlm; | |
| $("#detailsToggle").onclick = () => setDetails(!$("#scannerShell").classList.contains("details-open")); | |
| $("#detailsClose").onclick = () => setDetails(false); | |
| $("#uploadButton").onclick = () => $("#documentUpload").click(); | |
| $("#documentUpload").onchange = handleUpload; | |
| $("#documentInfo").onclick = () => { | |
| if (currentDocumentText) window.FalconInsights.explain(currentDocumentText); | |
| }; | |
| $("#language").onchange = () => { document.documentElement.lang = $("#language").value; }; | |
| window.addEventListener("resize", () => window.FalconOverlay.clear()); | |
| }); | |
| window.addEventListener("beforeunload", () => stream?.getTracks().forEach((track) => track.stop())); | |
| })(); | |