/** * app.js — ImageForensics-Detect Frontend * Handles file upload, API calls, result rendering, animations. */ // ──// Use current hostname to avoid CORS/Localhost resolution issues // For deployment (Vercel/HuggingFace), empty string "" uses current host/port const API_HOST = window.location.hostname; const API_BASE = (API_HOST === "" || API_HOST === "localhost" || API_HOST === "127.0.0.1") ? "http://localhost:8000" : ""; console.log("[Forensics] Host:", API_HOST || "local-file"); console.log("[Forensics] API Base:", API_BASE || "relative-root"); // ── State ──────────────────────────────────────────────────────── let selectedFile = null; let stepTimer = null; const STEPS = ["spectral", "edge", "cnn", "vit", "diffusion", "fusion"]; // ── DOM refs ───────────────────────────────────────────────────── const uploadZone = document.getElementById("uploadZone"); const fileInput = document.getElementById("fileInput"); const selectBtn = document.getElementById("selectBtn"); const previewArea = document.getElementById("previewArea"); const previewImg = document.getElementById("previewImg"); const fileNameEl = document.getElementById("fileName"); const fileSizeEl = document.getElementById("fileSize"); const analyzeBtn = document.getElementById("analyzeBtn"); const clearBtn = document.getElementById("clearBtn"); const analyzingState = document.getElementById("analyzingState"); const resultsSection = document.getElementById("resultsSection"); const errorBanner = document.getElementById("errorBanner"); const errorMsg = document.getElementById("errorMsg"); const serverStatus = document.getElementById("serverStatus"); // ── UI Helpers ─────────────────────────────────────────────────── function showError(msg) { errorMsg.textContent = msg; errorBanner.style.display = "flex"; } function hideError() { errorBanner.style.display = "none"; } // Fixed for hoisting: define resetUI as a window property but also a function window.resetUI = function resetUI() { selectedFile = null; if (fileInput) fileInput.value = ""; if (previewArea) previewArea.style.display = "none"; if (resultsSection) resultsSection.style.display = "none"; if (analyzingState) analyzingState.style.display = "none"; if (errorBanner) errorBanner.style.display = "none"; if (uploadZone) uploadZone.scrollIntoView({ behavior: "smooth" }); }; // ── Server health check ────────────────────────────────────────── async function checkServerHealth() { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); const r = await fetch(`${API_BASE}/health`, { signal: controller.signal }); clearTimeout(timeoutId); if (r.ok) { serverStatus.textContent = "● API Online"; serverStatus.classList.remove("offline"); } else { throw new Error("not ok"); } } catch (err) { if (err.name === 'AbortError') { // Silent timeout } else { console.warn("[Forensics] Health check failed:", err.message); } serverStatus.textContent = "● API Offline"; serverStatus.classList.add("offline"); } } // ── Analysis Steps Animation ────────────────────────────────────── function animateSteps() { let i = 0; console.log("[Forensics] Starting analysis animations..."); STEPS.forEach(s => { const el = document.getElementById(`step-${s}`); if (el) { el.className = "step-item"; const iconMatch = el.textContent.match(/^[✔○〇]\s*/); if (iconMatch) { el.textContent = "○ " + el.textContent.substring(iconMatch[0].length); } } }); stepTimer = setInterval(() => { if (i > 0) { const prev = document.getElementById(`step-${STEPS[i - 1]}`); if (prev) { prev.classList.remove("active"); prev.classList.add("done"); prev.textContent = "✔ " + prev.textContent.slice(2); } } if (i < STEPS.length) { const cur = document.getElementById(`step-${STEPS[i]}`); if (cur) cur.classList.add("active"); i++; } else { clearInterval(stepTimer); } }, 600); } function stopStepAnimation() { if (stepTimer) clearInterval(stepTimer); STEPS.forEach(s => { const el = document.getElementById(`step-${s}`); if (el) el.classList.remove("active"); }); } // ── Viz Loading Helper ─────────────────────────────────────────── function setVizImg(id, b64) { const el = document.getElementById(id); if (!el) return; if (b64) { el.src = `data:image/jpeg;base64,${b64}`; el.classList.add("loaded"); } else { el.classList.remove("loaded"); el.src = ""; } } // ── Result Rendering ───────────────────────────────────────────── function renderResults(data) { const isFake = data.prediction === "AI-Generated"; const conf = data.confidence; const probFake = data.prob_fake; const probReal = 1 - probFake; // ── Verdict Card ────────────────────────────────────────────── const verdictCard = document.getElementById("verdictCard"); if (verdictCard) verdictCard.className = `verdict-card ${isFake ? "fake" : "real"}`; const iconEl = document.getElementById("verdictIcon"); if (iconEl) iconEl.textContent = isFake ? "🤖" : "📷"; const vtEl = document.getElementById("verdictText"); if (vtEl) { vtEl.textContent = data.prediction; vtEl.className = `verdict-text ${isFake ? "fake" : "real"}`; } const vsEl = document.getElementById("verdictSub"); if (vsEl) { vsEl.textContent = `Overall confidence: ${conf.toFixed(1)}% · ${isFake ? "Characteristics of AI-generated content detected" : "Characteristics of a real camera photograph detected"}`; } // Probability Bar const pctFake = Math.round(probFake * 100); const pctReal = Math.round(probReal * 100); const pbReal = document.getElementById("probBarReal"); const pbFake = document.getElementById("probBarFake"); if (pbReal) pbReal.style.width = `${pctReal}%`; if (pbFake) pbFake.style.width = `${pctFake}%`; const prPct = document.getElementById("probRealPct"); const pfPct = document.getElementById("probFakePct"); if (prPct) prPct.textContent = `${pctReal}%`; if (pfPct) pfPct.textContent = `${pctFake}%`; // Confidence Ring const ring = document.getElementById("ringFill"); const confVal = document.getElementById("confValue"); if (ring && confVal) { const circumf = 2 * Math.PI * 50; ring.style.strokeDashoffset = circumf * (1 - conf / 100); ring.className = `ring-fill ${isFake ? "fake" : "real"}`; confVal.textContent = `${Math.round(conf)}%`; confVal.style.color = isFake ? "var(--fake)" : "var(--real)"; } // ── Branch Cards ────────────────────────────────────────────── const fused = data.fused_weights || {}; Object.entries(data.branches).forEach(([name, info]) => { const probFakeB = info.prob_fake; const conf_b = info.confidence; const weight = fused[name] || 0; const isFakeB = info.label === "AI-Generated"; const color = isFakeB ? "var(--fake)" : "var(--real)"; const card = document.getElementById(`bc-${name}`); if (card) card.style.borderLeft = `3px solid ${color}`; const bar = document.getElementById(`bar-${name}`); if (bar) { setTimeout(() => { bar.style.width = `${probFakeB * 100}%`; }, 100); bar.style.background = color; } const probEl = document.getElementById(`prob-${name}`); const confEl = document.getElementById(`conf-${name}`); const weightEl = document.getElementById(`weight-${name}`); if (probEl) { probEl.textContent = `${(probFakeB * 100).toFixed(1)}% fake`; probEl.style.color = color; } if (confEl) confEl.textContent = `conf: ${(conf_b * 100).toFixed(0)}%`; if (weightEl) { weightEl.textContent = `weight: ${(weight * 100).toFixed(1)}%`; weightEl.style.color = weight > 0.25 ? "var(--accent)" : "var(--text3)"; } }); // ── Visualizations ──────────────────────────────────────────── const gradcamImg = document.getElementById("img-gradcam"); const gradcamUnavail = document.getElementById("gradcam-unavail"); const gradcamLabel = document.getElementById("gradcam-label"); const gradcamSub = document.getElementById("gradcam-sub"); if (gradcamImg) { if (data.gradcam_available && data.gradcam_b64) { gradcamImg.src = `data:image/jpeg;base64,${data.gradcam_b64}`; gradcamImg.classList.add("loaded"); if (gradcamUnavail) gradcamUnavail.style.display = "none"; if (gradcamLabel) gradcamLabel.textContent = "Saliency / Grad-CAM Heatmap"; if (gradcamSub) gradcamSub.textContent = "Suspicious regions highlighted (JET colormap)"; } else { gradcamImg.classList.remove("loaded"); if (gradcamUnavail) gradcamUnavail.style.display = "flex"; if (gradcamLabel) gradcamLabel.textContent = "Grad-CAM Heatmap"; if (gradcamSub) gradcamSub.textContent = "Unavailable — CNN weights not loaded"; } } setVizImg("img-spectrum", data.spectrum_b64); setVizImg("img-spectrum-ann", data.spectrum_annotated_b64); setVizImg("img-noise", data.noise_map_b64); setVizImg("img-edge", data.edge_map_b64); const lcBanner = document.getElementById("lowCertaintyBanner"); if (lcBanner) lcBanner.style.display = data.low_certainty ? "block" : "none"; } // ── Main Analysis Logic ────────────────────────────────────────── async function runAnalysis() { console.log("[Forensics] runAnalysis() started for file:", selectedFile ? selectedFile.name : "none"); if (!selectedFile) { showError("Please select an image first."); return; } previewArea.style.display = "none"; resultsSection.style.display = "none"; errorBanner.style.display = "none"; analyzingState.style.display = "block"; animateSteps(); const formData = new FormData(); formData.append("file", selectedFile); try { console.log("[Forensics] Fetching /predict..."); const response = await fetch(`${API_BASE}/predict`, { method: "POST", body: formData, }); console.log("[Forensics] /predict status:", response.status); stopStepAnimation(); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || `Server error ${response.status}`); } const data = await response.json(); analyzingState.style.display = "none"; renderResults(data); resultsSection.style.display = "block"; resultsSection.scrollIntoView({ behavior: "smooth", block: "start" }); } catch (e) { console.error("[Forensics] Analysis error:", e); stopStepAnimation(); analyzingState.style.display = "none"; previewArea.style.display = "flex"; showError(`Analysis failed: ${e.message}. Make sure the API server is running.`); } } // ── File Handling ──────────────────────────────────────────────── function handleFile(file) { if (!file.type.startsWith("image/")) { showError("Please upload a valid image file (JPG, PNG, WebP, BMP)."); return; } if (file.size > 15 * 1024 * 1024) { showError("File too large. Maximum allowed size is 15 MB."); return; } selectedFile = file; const url = URL.createObjectURL(file); previewImg.src = url; fileNameEl.textContent = file.name; fileSizeEl.textContent = `${(file.size / 1024).toFixed(1)} KB · ${file.type}`; hideError(); previewArea.style.display = "flex"; } // ── Event Listeners ────────────────────────────────────────────── if (uploadZone) { uploadZone.addEventListener("dragover", e => { e.preventDefault(); uploadZone.classList.add("drag-over"); }); uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("drag-over")); uploadZone.addEventListener("drop", e => { e.preventDefault(); uploadZone.classList.remove("drag-over"); const file = e.dataTransfer.files[0]; if (file) handleFile(file); }); uploadZone.addEventListener("click", e => { if (e.target === selectBtn || selectBtn.contains(e.target)) return; fileInput.click(); }); } if (selectBtn) selectBtn.addEventListener("click", e => { e.stopPropagation(); fileInput.click(); }); if (fileInput) fileInput.addEventListener("change", () => { if (fileInput.files[0]) handleFile(fileInput.files[0]); }); if (clearBtn) clearBtn.addEventListener("click", window.resetUI); if (analyzeBtn) analyzeBtn.addEventListener("click", runAnalysis); // Initialize checkServerHealth(); setInterval(checkServerHealth, 15000);