Spaces:
Sleeping
Sleeping
| /** | |
| * AI Video Detector β Frontend Application | |
| * Handles: drag-drop upload, video preview, API call, animated gauge, results | |
| */ | |
| ; | |
| // ββ 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 = `<span class="toast-icon" aria-hidden="true">${icons[type]}</span><span>${message}</span>`; | |
| 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); | |
| })(); | |