/** * AI Video Detector – Frontend Application * Handles: drag-drop upload, video preview, API call, animated gauge, results */ "use strict"; // ── DOM References ──────────────────────────────────────────────────────────── const uploadZone = document.getElementById("upload-zone"); const fileInput = document.getElementById("file-input"); const previewArea = document.getElementById("preview-area"); const previewVideo = document.getElementById("preview-video"); const previewName = document.getElementById("preview-name"); const previewMeta = document.getElementById("preview-meta"); const btnRemove = document.getElementById("btn-remove"); const btnAnalyze = document.getElementById("btn-analyze"); const progressWrap = document.getElementById("progress-wrap"); const progressBar = document.getElementById("progress-bar"); const progressLbl = document.getElementById("progress-label"); const progressPct = document.getElementById("progress-pct"); const resultsPanel = document.getElementById("results-panel"); const resultCard = document.getElementById("result-card"); const verdictBadge = document.getElementById("verdict-badge"); const verdictIcon = document.getElementById("verdict-icon"); const verdictText = document.getElementById("verdict-text"); const gaugeFill = document.getElementById("gauge-fill"); const gaugePctTxt = document.getElementById("gauge-pct-text"); const mProb = document.getElementById("m-prob"); const mConf = document.getElementById("m-conf"); const mTime = document.getElementById("m-time"); const mThresh = document.getElementById("m-thresh"); const resultDesc = document.getElementById("result-desc"); const btnReset = document.getElementById("btn-reset"); const statusDot = document.getElementById("status-dot"); const statusText = document.getElementById("status-text"); const toastCont = document.getElementById("toast-container"); // Progress steps const psUpload = document.getElementById("ps-upload"); const psExtract = document.getElementById("ps-extract"); const psCnn = document.getElementById("ps-cnn"); const psLstm = document.getElementById("ps-lstm"); const psVerdict = document.getElementById("ps-verdict"); const pSteps = [psUpload, psExtract, psCnn, psLstm, psVerdict]; // ── State ───────────────────────────────────────────────────────────────────── let selectedFile = null; let analysisTimer = null; // ── Gauge constants ─────────────────────────────────────────────────────────── const GAUGE_CIRCUMFERENCE = 330; // stroke-dasharray value in SVG // ── Server Status Check ─────────────────────────────────────────────────────── async function checkServerStatus() { try { const res = await fetch("/api/status", { signal: AbortSignal.timeout(4000) }); if (res.ok) { const data = await res.json(); statusDot.classList.remove("offline"); statusText.textContent = `Model ready · ${data.device?.toUpperCase() ?? "CPU"}`; statusDot.setAttribute("title", "Server online"); return true; } } catch (_) {} statusDot.classList.add("offline"); statusText.textContent = "Server offline"; return false; } // ── Toast Notifications ─────────────────────────────────────────────────────── function toast(message, type = "info", duration = 4500) { const icons = { error: "❌", success: "✅", info: "ℹ️" }; const el = document.createElement("div"); el.className = `toast ${type}`; el.setAttribute("role", "alert"); el.innerHTML = `${message}`; toastCont.appendChild(el); setTimeout(() => { el.classList.add("toast-exit"); el.addEventListener("animationend", () => el.remove()); }, duration); } // ── File Handling ───────────────────────────────────────────────────────────── const ALLOWED_TYPES = ["video/mp4", "video/avi", "video/x-msvideo", "video/x-matroska", "video/quicktime", "video/webm", "video/x-ms-wmv"]; const MAX_SIZE_MB = 500; function isVideoFile(file) { if (ALLOWED_TYPES.includes(file.type)) return true; const ext = file.name.split(".").pop().toLowerCase(); return ["mp4","avi","mkv","mov","webm","wmv"].includes(ext); } function formatBytes(bytes) { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } function setFile(file) { if (!isVideoFile(file)) { toast("Unsupported file type. Please upload a video (MP4, AVI, MKV, MOV, WEBM).", "error"); return; } if (file.size > MAX_SIZE_MB * 1024 * 1024) { toast(`File too large. Maximum allowed size is ${MAX_SIZE_MB} MB.`, "error"); return; } selectedFile = file; // Preview const url = URL.createObjectURL(file); previewVideo.src = url; previewName.textContent = file.name; previewMeta.textContent = formatBytes(file.size); previewArea.classList.add("visible"); btnAnalyze.disabled = false; hideResults(); toast(`Video selected: ${file.name}`, "success", 3000); } function clearFile() { selectedFile = null; fileInput.value = ""; previewVideo.src = ""; previewArea.classList.remove("visible"); btnAnalyze.disabled = true; hideResults(); } // ── Upload Zone Events ──────────────────────────────────────────────────────── uploadZone.addEventListener("click", () => fileInput.click()); uploadZone.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") fileInput.click(); }); fileInput.addEventListener("change", () => { if (fileInput.files[0]) setFile(fileInput.files[0]); }); uploadZone.addEventListener("dragenter", e => { e.preventDefault(); uploadZone.classList.add("drag-over"); }); uploadZone.addEventListener("dragover", e => { e.preventDefault(); uploadZone.classList.add("drag-over"); }); uploadZone.addEventListener("dragleave", e => { if (!uploadZone.contains(e.relatedTarget)) uploadZone.classList.remove("drag-over"); }); uploadZone.addEventListener("drop", e => { e.preventDefault(); uploadZone.classList.remove("drag-over"); const files = e.dataTransfer?.files; if (files && files[0]) setFile(files[0]); }); btnRemove.addEventListener("click", clearFile); // ── Progress Simulation ─────────────────────────────────────────────────────── function setProgress(pct, label, activeStep) { progressBar.style.width = `${pct}%`; progressBar.parentElement.setAttribute("aria-valuenow", pct); progressLbl.textContent = label; progressPct.textContent = `${Math.round(pct)}%`; pSteps.forEach(s => { s.classList.remove("active", "done"); const idx = pSteps.indexOf(s); const activeIdx = pSteps.indexOf(activeStep); if (idx < activeIdx) s.classList.add("done"); else if (idx === activeIdx) s.classList.add("active"); }); } function startProgressSimulation() { clearTimeout(analysisTimer); setProgress(5, "Uploading video…", psUpload); progressWrap.classList.add("visible"); const steps = [ { delay: 800, pct: 20, label: "Extracting frames from video…", step: psExtract }, { delay: 2200, pct: 45, label: "Running CNN feature extraction…", step: psCnn }, { delay: 4000, pct: 70, label: "Processing LSTM temporal sequence…", step: psLstm }, { delay: 5500, pct: 90, label: "Computing classification verdict…", step: psVerdict }, ]; steps.forEach(({ delay, pct, label, step }) => { analysisTimer = setTimeout(() => setProgress(pct, label, step), delay); }); } function finishProgress() { clearTimeout(analysisTimer); setProgress(100, "Analysis complete!", psVerdict); psVerdict.classList.remove("active"); psVerdict.classList.add("done"); } // ── Gauge Animation ─────────────────────────────────────────────────────────── /** * The gauge covers ~280° of 360°. stroke-dasharray=330 corresponds to the arc. * A confidence of 0% → dashoffset=330 (empty), 100% → dashoffset=0 (full). */ function animateGauge(confidencePct, isAI) { const fillClass = isAI ? "ai-fill" : "real-fill"; gaugeFill.setAttribute("class", `gauge-fill ${fillClass}`); const offset = GAUGE_CIRCUMFERENCE - (confidencePct / 100) * GAUGE_CIRCUMFERENCE; // Start at empty gaugeFill.style.strokeDashoffset = GAUGE_CIRCUMFERENCE; gaugePctTxt.textContent = "--"; requestAnimationFrame(() => { requestAnimationFrame(() => { gaugeFill.style.strokeDashoffset = offset; }); }); // Animate number counter let start = 0; const end = confidencePct; const duration = 1200; const startTime = performance.now(); function step(now) { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); const ease = 1 - Math.pow(1 - progress, 3); start = Math.round(ease * end); gaugePctTxt.textContent = `${start}%`; if (progress < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } // ── Results Rendering ───────────────────────────────────────────────────────── function showResults(data) { const { verdict, is_ai, probability, confidence, processing_time, threshold } = data; // Card theme resultCard.classList.remove("ai-result", "real-result"); resultCard.classList.add(is_ai ? "ai-result" : "real-result"); // Verdict badge verdictBadge.classList.remove("ai-badge", "real-badge"); verdictBadge.classList.add(is_ai ? "ai-badge" : "real-badge"); verdictIcon.textContent = is_ai ? "🤖" : "✅"; verdictText.textContent = verdict; verdictBadge.setAttribute("aria-label", `Verdict: ${verdict}`); // Gauge animateGauge(Math.round(confidence), is_ai); // Metrics mProb.textContent = probability.toFixed(4); mProb.className = "metric-value"; mProb.classList.add(is_ai ? "red" : "green"); mConf.textContent = `${confidence.toFixed(1)}%`; mConf.className = "metric-value"; mConf.classList.add(is_ai ? "red" : "green"); mTime.textContent = `${processing_time}s`; mThresh.textContent = is_ai ? "No ✗" : "Yes ✓"; mThresh.className = `metric-value ${is_ai ? "red" : "green"}`; // Description resultDesc.className = "result-desc"; resultDesc.classList.add(is_ai ? "ai" : "real"); resultDesc.textContent = is_ai ? `This video shows strong indicators of AI generation. The model found temporal artifacts and synthetic patterns across the frame sequence — characteristic of AI-created content such as deepfakes or generative video models. Confidence: ${confidence.toFixed(1)}%.` : `This video exhibits natural, organic characteristics consistent with real-world footage. The temporal patterns and spatial features analysed across frames match those of authentic video capture. Confidence: ${confidence.toFixed(1)}%.`; resultsPanel.classList.add("visible"); } function hideResults() { resultsPanel.classList.remove("visible"); progressWrap.classList.remove("visible"); pSteps.forEach(s => s.classList.remove("active", "done")); } // ── Analyze ─────────────────────────────────────────────────────────────────── btnAnalyze.addEventListener("click", async () => { if (!selectedFile) return; const online = await checkServerStatus(); if (!online) { toast("Cannot connect to the server. Make sure app.py is running.", "error", 6000); return; } // UI: loading state btnAnalyze.disabled = true; btnAnalyze.classList.add("loading"); btnAnalyze.setAttribute("aria-busy", "true"); hideResults(); startProgressSimulation(); const formData = new FormData(); formData.append("video", selectedFile, selectedFile.name); try { const res = await fetch("/api/predict", { method: "POST", body: formData, }); finishProgress(); if (!res.ok) { let errMsg = `Server error ${res.status}`; try { const errData = await res.json(); errMsg = errData.error || errMsg; } catch (_) {} toast(`Analysis failed: ${errMsg}`, "error", 7000); return; } const data = await res.json(); // Brief pause so progress animation finishes await new Promise(r => setTimeout(r, 500)); showResults(data); toast( `Analysis complete: ${data.verdict} (${data.confidence.toFixed(1)}% confidence)`, data.is_ai ? "info" : "success", 5000 ); } catch (err) { toast(`Network error: ${err.message}`, "error", 7000); console.error(err); } finally { btnAnalyze.disabled = false; btnAnalyze.classList.remove("loading"); btnAnalyze.setAttribute("aria-busy", "false"); } }); // ── Reset ───────────────────────────────────────────────────────────────────── btnReset.addEventListener("click", () => { clearFile(); hideResults(); window.scrollTo({ top: 0, behavior: "smooth" }); toast("Ready for a new analysis.", "info", 2500); }); // ── Init ────────────────────────────────────────────────────────────────────── (async function init() { await checkServerStatus(); // Recheck every 30 seconds setInterval(checkServerStatus, 30_000); })();