MrTsp commited on
Commit
8bc8b01
Β·
verified Β·
1 Parent(s): 32eee2a

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +36 -0
  2. README.md +12 -5
  3. app.py +342 -0
  4. requirements.txt +10 -0
  5. static/index.html +219 -0
  6. static/style.css +572 -0
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # System deps
6
+ RUN apt-get update && apt-get install -y \
7
+ ffmpeg \
8
+ libgl1 \
9
+ libglib2.0-0 \
10
+ libsm6 \
11
+ libxext6 \
12
+ libxrender-dev \
13
+ git \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Install CPU-only PyTorch first
17
+ RUN pip install --no-cache-dir \
18
+ torch==2.4.0+cpu \
19
+ torchvision==0.19.0+cpu \
20
+ --index-url https://download.pytorch.org/whl/cpu
21
+
22
+ # Install remaining deps
23
+ COPY requirements.txt .
24
+ RUN pip install --no-cache-dir -r requirements.txt
25
+ RUN pip install --no-deps --no-cache-dir facenet-pytorch==2.6.0
26
+
27
+ # Copy app + static frontend files
28
+ COPY app.py .
29
+ COPY static/ ./static/
30
+ # Copy model checkpoint (uploaded separately to HF Space repo)
31
+ COPY best_model.pth .
32
+
33
+ # HF Spaces requires port 7860
34
+ EXPOSE 7860
35
+
36
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--timeout-keep-alive", "120"]
README.md CHANGED
@@ -1,11 +1,18 @@
1
  ---
2
- title: DeepShield Web2
3
- emoji: πŸŒ–
4
- colorFrom: red
5
  colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
- short_description: updated
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
1
  ---
2
+ title: DeepShield AI
3
+ emoji: πŸ›‘οΈ
4
+ colorFrom: purple
5
  colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
+ license: mit
9
  ---
10
 
11
+ # DeepShield AI β€” Deepfake Detector (DINO-G50)
12
+
13
+ Full-stack deepfake detection web app powered by DINO-G50 Vision AI.
14
+
15
+ Upload a video β†’ frames extract β†’ AI analyzes each frame β†’ REAL or FAKE verdict with % confidence.
16
+
17
+ ## Files needed
18
+ - `best_model.pth` β€” Upload manually to this Space (450MB model checkpoint)
app.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeepShield AI β€” Full-Stack FastAPI Backend
3
+ Serves the frontend UI + deepfake detection API from one HF Space.
4
+
5
+ Routes:
6
+ GET / β†’ Serves index.html (the web UI)
7
+ GET /health β†’ JSON health check
8
+ POST /predict β†’ Video upload β†’ REAL/FAKE prediction
9
+ """
10
+
11
+ import os
12
+ import sys
13
+ import uuid
14
+ import shutil
15
+ import logging
16
+ import tempfile
17
+ from pathlib import Path
18
+ from functools import lru_cache
19
+
20
+ import cv2
21
+ import torch
22
+ import torch.nn as nn
23
+ import numpy as np
24
+ from PIL import Image, ImageFile
25
+ from facenet_pytorch import MTCNN
26
+ from fastapi import FastAPI, File, UploadFile, HTTPException
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+ from fastapi.responses import JSONResponse, FileResponse
29
+ from fastapi.staticfiles import StaticFiles
30
+ import torchvision.transforms as T
31
+
32
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
33
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ─────────────────────────────────────────────
37
+ # Model Definition (self-contained)
38
+ # ─────────────────────────────────────────────
39
+
40
+ class DINOv2Extractor(nn.Module):
41
+ def __init__(self, variant: str = "dinov2_vitb14"):
42
+ super().__init__()
43
+ logger.info(f"Loading {variant} from torch.hub...")
44
+ self.backbone = torch.hub.load(
45
+ "facebookresearch/dinov2", variant, pretrained=True
46
+ )
47
+ self.feature_dim = 768
48
+ for p in self.backbone.parameters():
49
+ p.requires_grad = False
50
+ logger.info("DINOv2 backbone loaded (frozen).")
51
+
52
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
53
+ return self.backbone(x)
54
+
55
+
56
+ class MLPClassifier(nn.Module):
57
+ def __init__(self, input_dim: int = 1536, num_classes: int = 2, dropout: float = 0.3):
58
+ super().__init__()
59
+ self.net = nn.Sequential(
60
+ nn.Linear(input_dim, 512),
61
+ nn.LayerNorm(512),
62
+ nn.GELU(),
63
+ nn.Dropout(dropout),
64
+ nn.Linear(512, 256),
65
+ nn.LayerNorm(256),
66
+ nn.GELU(),
67
+ nn.Dropout(dropout / 2),
68
+ nn.Linear(256, num_classes),
69
+ )
70
+
71
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
72
+ return self.net(x)
73
+
74
+
75
+ class DeepfakeDetector(nn.Module):
76
+ def __init__(self, dual_input: bool = True):
77
+ super().__init__()
78
+ self.dual_input = dual_input
79
+ self.extractor = DINOv2Extractor()
80
+ feat_dim = 1536 if dual_input else 768
81
+ self.classifier = MLPClassifier(input_dim=feat_dim)
82
+
83
+ def forward(self, full_img: torch.Tensor, face_img: torch.Tensor = None) -> torch.Tensor:
84
+ full_feat = self.extractor(full_img)
85
+ if self.dual_input and face_img is not None:
86
+ face_feat = self.extractor(face_img)
87
+ feats = torch.cat([full_feat, face_feat], dim=1)
88
+ else:
89
+ feats = full_feat
90
+ return self.classifier(feats)
91
+
92
+
93
+ # ─────────────────────────────────────────────
94
+ # App Setup
95
+ # ─────────────────────────────────────────────
96
+
97
+ app = FastAPI(
98
+ title="DeepShield AI",
99
+ description="DINO-G50 deepfake detector β€” full-stack web app",
100
+ version="2.0.0",
101
+ )
102
+
103
+ app.add_middleware(
104
+ CORSMiddleware,
105
+ allow_origins=["*"],
106
+ allow_credentials=True,
107
+ allow_methods=["*"],
108
+ allow_headers=["*"],
109
+ )
110
+
111
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
112
+ CHECKPOINT_PATH = Path("best_model.pth")
113
+ MAX_FRAMES = 20
114
+ MAX_FILE_MB = 30
115
+ MAX_DURATION_SEC = 60
116
+
117
+ # MTCNN face detector (initialized once, CPU is fine for detection)
118
+ try:
119
+ MTCNN_DETECTOR = MTCNN(
120
+ image_size=224,
121
+ margin=40,
122
+ min_face_size=20,
123
+ thresholds=[0.6, 0.7, 0.9],
124
+ keep_all=False,
125
+ device='cpu'
126
+ )
127
+ logger.info("MTCNN face detector initialized.")
128
+ except Exception as e:
129
+ MTCNN_DETECTOR = None
130
+ logger.warning(f"MTCNN init failed (will use full frame fallback): {e}")
131
+
132
+ TRANSFORM = T.Compose([
133
+ T.Resize((224, 224)),
134
+ T.ToTensor(),
135
+ T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
136
+ ])
137
+
138
+
139
+ def detect_face_crop(img: Image.Image) -> Image.Image:
140
+ """Detect face with MTCNN and return cropped face, or None if not found."""
141
+ if MTCNN_DETECTOR is None:
142
+ return None
143
+ try:
144
+ # MTCNN returns the cropped tensor directly
145
+ face_tensor = MTCNN_DETECTOR(img)
146
+ if face_tensor is not None:
147
+ # Convert tensor back to PIL Image
148
+ face_np = face_tensor.permute(1, 2, 0).numpy()
149
+ face_np = ((face_np * 128) + 127.5).clip(0, 255).astype(np.uint8)
150
+ return Image.fromarray(face_np)
151
+ except Exception:
152
+ pass
153
+ return None
154
+
155
+
156
+ @lru_cache(maxsize=1)
157
+ def load_model() -> DeepfakeDetector:
158
+ if not CHECKPOINT_PATH.exists():
159
+ raise RuntimeError("best_model.pth not found. Upload it to this HF Space.")
160
+
161
+ logger.info(f"Loading checkpoint on {DEVICE}...")
162
+ ckpt = torch.load(CHECKPOINT_PATH, map_location=DEVICE)
163
+ state = ckpt.get("model_state_dict", ckpt)
164
+
165
+ mlp_w = state.get("classifier.net.0.weight", None)
166
+ dual = (mlp_w.shape[1] == 1536) if mlp_w is not None else True
167
+
168
+ model = DeepfakeDetector(dual_input=dual).to(DEVICE)
169
+ model.load_state_dict(state, strict=False)
170
+ model.eval()
171
+ logger.info(f"Model ready. dual_input={dual}, device={DEVICE}")
172
+ return model
173
+
174
+
175
+ def extract_frames(video_path: str, output_dir: str, num_frames: int = MAX_FRAMES) -> list:
176
+ cap = cv2.VideoCapture(video_path)
177
+ if not cap.isOpened():
178
+ raise ValueError("Cannot open video file.")
179
+
180
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
181
+ fps = cap.get(cv2.CAP_PROP_FPS) or 25
182
+ duration = total_frames / fps if fps > 0 else 0
183
+
184
+ if duration > MAX_DURATION_SEC:
185
+ cap.release()
186
+ raise ValueError(f"Video too long ({duration:.0f}s). Max: {MAX_DURATION_SEC}s.")
187
+
188
+ if total_frames <= 0:
189
+ total_frames = int(fps * MAX_DURATION_SEC)
190
+
191
+ step = max(1, total_frames // num_frames)
192
+ target_indices = set(range(0, total_frames, step))
193
+ saved_paths = []
194
+ frame_idx = 0
195
+
196
+ while len(saved_paths) < num_frames:
197
+ ret, frame = cap.read()
198
+ if not ret:
199
+ break
200
+ if frame_idx in target_indices:
201
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
202
+ path = os.path.join(output_dir, f"frame_{len(saved_paths):04d}.jpg")
203
+ Image.fromarray(rgb).save(path, quality=90)
204
+ saved_paths.append(path)
205
+ frame_idx += 1
206
+
207
+ cap.release()
208
+ return saved_paths
209
+
210
+
211
+ def run_inference(model: DeepfakeDetector, frame_paths: list) -> dict:
212
+ fake_probs = []
213
+ with torch.no_grad():
214
+ for fpath in frame_paths:
215
+ try:
216
+ img = Image.open(fpath).convert("RGB")
217
+ t_img = TRANSFORM(img).unsqueeze(0).to(DEVICE)
218
+
219
+ # Try MTCNN face detection first (same as test_real.py)
220
+ t_face = t_img # default fallback = full frame
221
+ if model.dual_input:
222
+ face_crop = detect_face_crop(img)
223
+ if face_crop is not None:
224
+ t_face = TRANSFORM(face_crop).unsqueeze(0).to(DEVICE)
225
+ # else: fallback to full image (face not detected)
226
+
227
+ logits = model(t_img, t_face if model.dual_input else None)
228
+ prob = torch.softmax(logits, dim=1)[0, 1].item()
229
+ fake_probs.append(prob)
230
+ except Exception as e:
231
+ logger.warning(f"Skipping frame {fpath}: {e}")
232
+
233
+ if not fake_probs:
234
+ raise ValueError("No frames could be processed.")
235
+
236
+ # 1. Advanced Aggregation (Top 50% Mean)
237
+ # Deepfake artifacts might only appear in parts of the video.
238
+ # Averaging all frames dilutes the score. We take the top 50% most suspicious frames.
239
+ sorted_probs = sorted(fake_probs, reverse=True)
240
+ top_k = max(1, len(sorted_probs) // 2)
241
+ video_fake_prob = float(np.mean(sorted_probs[:top_k]))
242
+
243
+ # 2. Ratio Check
244
+ # If at least 30% of frames are distinctly flagged as Fake, mark the whole video as Fake.
245
+ fake_frame_count = sum(1 for p in fake_probs if p > 0.5)
246
+ fake_ratio = fake_frame_count / len(fake_probs)
247
+
248
+ is_fake = (video_fake_prob > 0.5) or (fake_ratio >= 0.3)
249
+
250
+ # Ensure UI consistency: If flagged as FAKE by ratio, but probability is low, boost it to 51%
251
+ if is_fake and video_fake_prob <= 0.5:
252
+ video_fake_prob = 0.51
253
+
254
+ avg_real = 1.0 - video_fake_prob
255
+
256
+ return {
257
+ "verdict": "FAKE" if is_fake else "REAL",
258
+ "fake_probability": round(video_fake_prob * 100, 1),
259
+ "real_probability": round(avg_real * 100, 1),
260
+ "frame_count": len(fake_probs),
261
+ "confidence": round(max(video_fake_prob, avg_real) * 100, 1),
262
+ "per_frame_scores": [round(p * 100, 1) for p in fake_probs],
263
+ }
264
+
265
+
266
+ # ─────────────────────────────────────────────
267
+ # API Routes (must be defined BEFORE static mount)
268
+ # ─────────────────────────────────────────────
269
+
270
+ @app.on_event("startup")
271
+ async def startup_event():
272
+ try:
273
+ load_model()
274
+ except Exception as e:
275
+ logger.error(f"Startup model load failed: {e}")
276
+
277
+
278
+ @app.get("/health")
279
+ def health_check():
280
+ return {
281
+ "status": "ok",
282
+ "model": "DINO-G50 Deepfake Detector",
283
+ "device": str(DEVICE),
284
+ "model_loaded": CHECKPOINT_PATH.exists(),
285
+ }
286
+
287
+
288
+ @app.post("/predict")
289
+ async def predict(file: UploadFile = File(...)):
290
+ allowed_exts = {".mp4", ".mov", ".avi", ".mkv"}
291
+ ext = Path(file.filename).suffix.lower() if file.filename else ""
292
+
293
+ if ext not in allowed_exts:
294
+ raise HTTPException(400, f"Unsupported type '{ext}'. Use: {allowed_exts}")
295
+
296
+ content = await file.read()
297
+ size_mb = len(content) / (1024 * 1024)
298
+ if size_mb > MAX_FILE_MB:
299
+ raise HTTPException(413, f"File too large ({size_mb:.1f} MB). Max: {MAX_FILE_MB} MB.")
300
+
301
+ job_id = str(uuid.uuid4())[:8]
302
+ temp_dir = Path(tempfile.gettempdir()) / f"deepshield_{job_id}"
303
+ frames_dir = temp_dir / "frames"
304
+ frames_dir.mkdir(parents=True, exist_ok=True)
305
+ video_path = temp_dir / f"input{ext}"
306
+
307
+ try:
308
+ with open(video_path, "wb") as f:
309
+ f.write(content)
310
+ del content
311
+
312
+ model = load_model()
313
+ logger.info(f"[{job_id}] Processing: {file.filename} ({size_mb:.1f} MB)")
314
+
315
+ frame_paths = extract_frames(str(video_path), str(frames_dir))
316
+ if not frame_paths:
317
+ raise HTTPException(422, "No frames could be extracted from video.")
318
+
319
+ result = run_inference(model, frame_paths)
320
+ result["filename"] = file.filename
321
+ result["file_size_mb"] = round(size_mb, 2)
322
+ result["job_id"] = job_id
323
+
324
+ logger.info(f"[{job_id}] Result: {result['verdict']} ({result['fake_probability']}% fake)")
325
+ return JSONResponse(content=result)
326
+
327
+ except HTTPException:
328
+ raise
329
+ except ValueError as e:
330
+ raise HTTPException(422, str(e))
331
+ except Exception as e:
332
+ logger.error(f"[{job_id}] Error: {e}", exc_info=True)
333
+ raise HTTPException(500, f"Internal error: {str(e)}")
334
+ finally:
335
+ shutil.rmtree(temp_dir, ignore_errors=True)
336
+ logger.info(f"[{job_id}] Cleanup done.")
337
+
338
+
339
+ # ─────────────────────────────────────────────
340
+ # Static Frontend (mounted LAST β€” serves index.html at /)
341
+ # ─────────────────────────────────────────────
342
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ fastapi==0.115.0
3
+ uvicorn[standard]==0.30.6
4
+ python-multipart==0.0.9
5
+ torch==2.4.0+cpu
6
+ torchvision==0.19.0+cpu
7
+ Pillow==10.4.0
8
+ opencv-python-headless==4.10.0.84
9
+ numpy==1.26.4
10
+ tqdm==4.66.5
static/index.html ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>DeepShield AI β€” Deepfake Detector</title>
7
+ <meta name="description" content="Upload a video and detect deepfakes instantly using DINO-G50 AI with confidence scores and per-frame analysis." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet" />
11
+ <link rel="stylesheet" href="style.css" />
12
+ </head>
13
+ <body>
14
+
15
+ <!-- Animated Background -->
16
+ <div class="bg-orbs">
17
+ <div class="orb orb-1"></div>
18
+ <div class="orb orb-2"></div>
19
+ <div class="orb orb-3"></div>
20
+ </div>
21
+
22
+ <!-- Navbar -->
23
+ <nav class="navbar">
24
+ <div class="nav-brand" onclick="location.reload()" style="cursor:pointer" title="Click to refresh">
25
+ <span class="brand-icon">πŸ›‘οΈ</span>
26
+ <span class="brand-name">DeepShield <span class="brand-accent">AI</span></span>
27
+ </div>
28
+ <div class="nav-right" style="display:flex; align-items:center; gap:16px;">
29
+ <div id="server-status" class="server-status status-checking">
30
+ <span class="status-dot"></span>
31
+ <span id="status-text">Checking API...</span>
32
+ </div>
33
+ <div class="nav-badge">DINO-G50 Powered</div>
34
+ </div>
35
+ </nav>
36
+
37
+ <!-- Hero -->
38
+ <header class="hero">
39
+ <div class="hero-tag">⚑ Real-time Detection</div>
40
+ <h1 class="hero-title">
41
+ Detect <span class="gradient-text">Deepfakes</span><br />Instantly
42
+ </h1>
43
+ <p class="hero-subtitle">
44
+ Upload any video. Our DINO-G50 Vision AI analyzes every frame<br />
45
+ and tells you exactly how likely a video is to be fake.
46
+ </p>
47
+ </header>
48
+
49
+ <!-- Main Card -->
50
+ <main class="main-card">
51
+
52
+ <!-- ── Upload Section ── -->
53
+ <section id="upload-section" class="upload-section">
54
+ <div
55
+ id="drop-zone"
56
+ class="drop-zone"
57
+ role="button"
58
+ tabindex="0"
59
+ aria-label="Upload video file"
60
+ ondragover="onDragOver(event)"
61
+ ondragleave="onDragLeave(event)"
62
+ ondrop="onDrop(event)"
63
+ onclick="document.getElementById('file-input').click()"
64
+ onkeypress="if(event.key==='Enter') document.getElementById('file-input').click()"
65
+ >
66
+ <div class="drop-icon">
67
+ <svg id="upload-icon" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
68
+ <circle cx="32" cy="32" r="30" fill="rgba(139,92,246,0.12)" stroke="rgba(139,92,246,0.4)" stroke-width="1.5"/>
69
+ <path d="M32 44V28M32 28L24 36M32 28L40 36" stroke="url(#g1)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
70
+ <path d="M20 44h24" stroke="url(#g1)" stroke-width="2" stroke-linecap="round"/>
71
+ <defs>
72
+ <linearGradient id="g1" x1="20" y1="28" x2="44" y2="44" gradientUnits="userSpaceOnUse">
73
+ <stop stop-color="#8B5CF6"/><stop offset="1" stop-color="#06B6D4"/>
74
+ </linearGradient>
75
+ </defs>
76
+ </svg>
77
+ </div>
78
+ <p class="drop-title">Drop your video here</p>
79
+ <p class="drop-sub">or <span class="link-text">browse files</span></p>
80
+ <p class="drop-limits">MP4 Β· MOV Β· AVI Β· MKV &nbsp;|&nbsp; Max 30 MB &nbsp;|&nbsp; Max 60 sec</p>
81
+ </div>
82
+ <input
83
+ id="file-input"
84
+ type="file"
85
+ accept=".mp4,.mov,.avi,.mkv,video/*"
86
+ style="display:none"
87
+ onchange="onFileSelected(event)"
88
+ />
89
+
90
+ <!-- File Preview -->
91
+ <div id="file-preview" class="file-preview hidden">
92
+ <div class="file-info">
93
+ <span class="file-icon">🎬</span>
94
+ <div class="file-meta">
95
+ <span id="file-name" class="file-name"></span>
96
+ <span id="file-size" class="file-size"></span>
97
+ </div>
98
+ <button class="remove-btn" onclick="resetUpload()" aria-label="Remove file">βœ•</button>
99
+ </div>
100
+ <div id="video-container" class="video-container">
101
+ <video id="video-preview" class="video-preview" controls muted></video>
102
+ </div>
103
+ <button id="analyze-btn" class="analyze-btn" onclick="analyzeVideo()">
104
+ <span class="btn-icon">πŸ”</span>
105
+ <span>Analyze for Deepfakes</span>
106
+ </button>
107
+ </div>
108
+ </section>
109
+
110
+ <!-- ── Loading Section ── -->
111
+ <section id="loading-section" class="loading-section hidden">
112
+ <div class="loading-animation">
113
+ <div class="spinner-ring"></div>
114
+ <div class="spinner-ring ring-2"></div>
115
+ <div class="spinner-ring ring-3"></div>
116
+ <div class="spinner-center">πŸ€–</div>
117
+ </div>
118
+ <h3 class="loading-title">Analyzing Video...</h3>
119
+ <div class="loading-steps">
120
+ <div id="step-1" class="step active">
121
+ <span class="step-dot"></span>
122
+ <span>Extracting frames</span>
123
+ </div>
124
+ <div id="step-2" class="step">
125
+ <span class="step-dot"></span>
126
+ <span>Running DINOv2 inference</span>
127
+ </div>
128
+ <div id="step-3" class="step">
129
+ <span class="step-dot"></span>
130
+ <span>Generating results</span>
131
+ </div>
132
+ </div>
133
+ <p class="loading-note">⏳ This may take 30–90 seconds on CPU. Please wait…</p>
134
+ </section>
135
+
136
+ <!-- ── Results Section ── -->
137
+ <section id="results-section" class="results-section hidden">
138
+
139
+ <!-- Verdict Card -->
140
+ <div id="verdict-card" class="verdict-card">
141
+ <div class="verdict-left">
142
+ <div class="verdict-circle-wrap">
143
+ <svg class="verdict-ring" viewBox="0 0 120 120">
144
+ <circle cx="60" cy="60" r="50" class="ring-bg"/>
145
+ <circle id="ring-fill" cx="60" cy="60" r="50" class="ring-progress"/>
146
+ </svg>
147
+ <div class="verdict-inner">
148
+ <span id="verdict-pct" class="verdict-pct">0%</span>
149
+ <span id="verdict-label" class="verdict-label">FAKE</span>
150
+ </div>
151
+ </div>
152
+ <p class="verdict-desc">Probability of being fake</p>
153
+ </div>
154
+
155
+ <div class="verdict-right">
156
+ <div id="verdict-badge" class="verdict-badge">⚠ FAKE</div>
157
+ <div class="verdict-stats">
158
+ <div class="stat-row">
159
+ <span class="stat-label">🎭 Fake probability</span>
160
+ <span id="stat-fake" class="stat-val fake-val">β€”</span>
161
+ </div>
162
+ <div class="stat-row">
163
+ <span class="stat-label">βœ… Real probability</span>
164
+ <span id="stat-real" class="stat-val real-val">β€”</span>
165
+ </div>
166
+ <div class="stat-row">
167
+ <span class="stat-label">🎞 Frames analyzed</span>
168
+ <span id="stat-frames" class="stat-val">β€”</span>
169
+ </div>
170
+ <div class="stat-row">
171
+ <span class="stat-label">πŸ“ File size</span>
172
+ <span id="stat-size" class="stat-val">β€”</span>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+
178
+ <!-- Per-Frame Chart -->
179
+ <div class="chart-card">
180
+ <h3 class="chart-title">πŸ“Š Per-Frame Detection Scores</h3>
181
+ <p class="chart-sub">Higher bar = more likely FAKE for that frame</p>
182
+ <div id="frame-chart" class="frame-chart"></div>
183
+ <div class="chart-legend">
184
+ <span class="legend-item"><span class="dot dot-fake"></span>Fake</span>
185
+ <span class="legend-item"><span class="dot dot-real"></span>Real</span>
186
+ <span class="legend-item"><span class="dot dot-thresh"></span>50% Threshold</span>
187
+ </div>
188
+ </div>
189
+
190
+ <!-- Actions -->
191
+ <div class="result-actions">
192
+ <button class="action-btn action-primary" onclick="resetUpload()">
193
+ πŸ”„ Analyze Another Video
194
+ </button>
195
+ <button class="action-btn action-secondary" onclick="copyResult()">
196
+ πŸ“‹ Copy Result
197
+ </button>
198
+ </div>
199
+ </section>
200
+
201
+ <!-- ── Error Section ── -->
202
+ <section id="error-section" class="error-section hidden">
203
+ <div class="error-icon">❌</div>
204
+ <h3 class="error-title">Analysis Failed</h3>
205
+ <p id="error-msg" class="error-msg"></p>
206
+ <button class="action-btn action-primary" onclick="resetUpload()">Try Again</button>
207
+ </section>
208
+
209
+ </main>
210
+
211
+ <!-- Footer -->
212
+ <footer class="footer">
213
+ <p><strong>MADE BY G50</strong></p>
214
+ <p class="footer-note">All Rights Reserved Β© G50 &nbsp;Β·&nbsp; Max 30 MB Β· 60 sec Β· Results are probabilistic, not legal evidence</p>
215
+ </footer>
216
+
217
+ <script src="script.js"></script>
218
+ </body>
219
+ </html>
static/style.css ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ──────────────────────────────────────────
2
+ DeepShield AI β€” style.css
3
+ Premium dark glassmorphism design
4
+ ────────────────────────────────────────── */
5
+
6
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
7
+
8
+ :root {
9
+ --bg: #08080f;
10
+ --surface: rgba(255,255,255,0.04);
11
+ --surface-2: rgba(255,255,255,0.07);
12
+ --border: rgba(255,255,255,0.08);
13
+ --border-glow: rgba(139,92,246,0.35);
14
+ --text: #f0f0ff;
15
+ --text-sub: rgba(240,240,255,0.55);
16
+ --purple: #8B5CF6;
17
+ --cyan: #06B6D4;
18
+ --green: #22c55e;
19
+ --red: #ef4444;
20
+ --orange: #f97316;
21
+ --radius: 18px;
22
+ --radius-sm: 10px;
23
+ --transition: 0.3s cubic-bezier(0.4,0,0.2,1);
24
+ }
25
+
26
+ html { scroll-behavior: smooth; }
27
+
28
+ body {
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ font-family: 'Inter', sans-serif;
32
+ font-size: 15px;
33
+ line-height: 1.6;
34
+ min-height: 100vh;
35
+ overflow-x: hidden;
36
+ }
37
+
38
+ /* ── Background Orbs ── */
39
+ .bg-orbs { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
40
+ .orb {
41
+ position: absolute;
42
+ border-radius: 50%;
43
+ filter: blur(80px);
44
+ opacity: 0.18;
45
+ animation: drift 12s ease-in-out infinite alternate;
46
+ }
47
+ .orb-1 { width: 500px; height: 500px; background: var(--purple); top: -150px; left: -100px; animation-delay: 0s; }
48
+ .orb-2 { width: 400px; height: 400px; background: var(--cyan); bottom: -100px; right: -80px; animation-delay: -5s; }
49
+ .orb-3 { width: 300px; height: 300px; background: #ec4899; top: 40%; left: 60%; animation-delay: -9s; }
50
+
51
+ @keyframes drift {
52
+ from { transform: translate(0, 0) scale(1); }
53
+ to { transform: translate(30px, 20px) scale(1.08); }
54
+ }
55
+
56
+ /* ── Navbar ── */
57
+ .navbar {
58
+ position: sticky;
59
+ top: 0;
60
+ z-index: 100;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ padding: 14px 32px;
65
+ background: rgba(8,8,15,0.85);
66
+ backdrop-filter: blur(20px);
67
+ border-bottom: 1px solid var(--border);
68
+ }
69
+ .nav-brand { display: flex; align-items: center; gap: 10px; }
70
+ .brand-icon { font-size: 24px; }
71
+ .brand-name {
72
+ font-family: 'Space Grotesk', sans-serif;
73
+ font-size: 20px;
74
+ font-weight: 700;
75
+ color: var(--text);
76
+ }
77
+ .brand-accent { color: var(--purple); }
78
+ .nav-badge {
79
+ font-size: 11px;
80
+ font-weight: 600;
81
+ padding: 4px 12px;
82
+ border-radius: 99px;
83
+ background: rgba(139,92,246,0.15);
84
+ border: 1px solid rgba(139,92,246,0.3);
85
+ color: var(--purple);
86
+ letter-spacing: 0.5px;
87
+ text-transform: uppercase;
88
+ }
89
+
90
+ /* ── Server Status Badge ── */
91
+ .server-status {
92
+ display: flex; align-items: center; gap: 8px;
93
+ font-size: 12px; font-weight: 600;
94
+ padding: 4px 12px; border-radius: 99px;
95
+ background: var(--surface-2); border: 1px solid var(--border);
96
+ transition: all 0.3s;
97
+ }
98
+ .status-dot { width: 8px; height: 8px; border-radius: 50%; }
99
+ .status-checking { color: #facc15; border-color: rgba(250,204,21,0.3); background: rgba(250,204,21,0.1); }
100
+ .status-checking .status-dot { background: #facc15; animation: pulse-dot 1s infinite alternate; }
101
+ .status-connected { color: #4ade80; border-color: rgba(74,222,128,0.3); background: rgba(74,222,128,0.1); }
102
+ .status-connected .status-dot { background: #4ade80; box-shadow: 0 0 8px #4ade80; }
103
+ .status-error { color: #f87171; border-color: rgba(248,113,113,0.3); background: rgba(248,113,113,0.1); }
104
+ .status-error .status-dot { background: #f87171; box-shadow: 0 0 8px #f87171; }
105
+
106
+
107
+ /* ── Hero ── */
108
+ .hero {
109
+ text-align: center;
110
+ padding: 60px 24px 36px;
111
+ position: relative;
112
+ z-index: 1;
113
+ }
114
+ .hero-tag {
115
+ display: inline-block;
116
+ font-size: 12px;
117
+ font-weight: 600;
118
+ text-transform: uppercase;
119
+ letter-spacing: 1.5px;
120
+ padding: 6px 16px;
121
+ border-radius: 99px;
122
+ background: rgba(6,182,212,0.1);
123
+ border: 1px solid rgba(6,182,212,0.25);
124
+ color: var(--cyan);
125
+ margin-bottom: 20px;
126
+ }
127
+ .hero-title {
128
+ font-family: 'Space Grotesk', sans-serif;
129
+ font-size: clamp(36px, 6vw, 64px);
130
+ font-weight: 700;
131
+ line-height: 1.1;
132
+ margin-bottom: 18px;
133
+ letter-spacing: -1px;
134
+ }
135
+ .gradient-text {
136
+ background: linear-gradient(135deg, var(--purple), var(--cyan));
137
+ -webkit-background-clip: text;
138
+ -webkit-text-fill-color: transparent;
139
+ background-clip: text;
140
+ }
141
+ .hero-subtitle {
142
+ color: var(--text-sub);
143
+ font-size: 17px;
144
+ max-width: 540px;
145
+ margin: 0 auto;
146
+ line-height: 1.7;
147
+ }
148
+
149
+ /* ── Main Card ── */
150
+ .main-card {
151
+ position: relative;
152
+ z-index: 1;
153
+ max-width: 820px;
154
+ margin: 0 auto 60px;
155
+ padding: 40px 32px;
156
+ background: var(--surface);
157
+ border: 1px solid var(--border);
158
+ border-radius: 28px;
159
+ backdrop-filter: blur(24px);
160
+ box-shadow: 0 32px 80px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.03) inset;
161
+ }
162
+
163
+ /* ── Drop Zone ── */
164
+ .drop-zone {
165
+ display: flex;
166
+ flex-direction: column;
167
+ align-items: center;
168
+ justify-content: center;
169
+ gap: 10px;
170
+ padding: 52px 32px;
171
+ border: 2px dashed rgba(139,92,246,0.3);
172
+ border-radius: var(--radius);
173
+ background: rgba(139,92,246,0.04);
174
+ cursor: pointer;
175
+ transition: all var(--transition);
176
+ outline: none;
177
+ }
178
+ .drop-zone:hover, .drop-zone:focus, .drop-zone.dragging {
179
+ border-color: var(--purple);
180
+ background: rgba(139,92,246,0.1);
181
+ box-shadow: 0 0 0 4px rgba(139,92,246,0.12);
182
+ transform: translateY(-2px);
183
+ }
184
+ .drop-icon svg { width: 64px; height: 64px; }
185
+ .drop-title { font-size: 19px; font-weight: 600; color: var(--text); }
186
+ .drop-sub { color: var(--text-sub); }
187
+ .link-text { color: var(--purple); text-decoration: underline; cursor: pointer; }
188
+ .drop-limits {
189
+ font-size: 12px;
190
+ color: var(--text-sub);
191
+ opacity: 0.7;
192
+ margin-top: 4px;
193
+ letter-spacing: 0.3px;
194
+ }
195
+
196
+ /* ── File Preview ── */
197
+ .file-preview { display: flex; flex-direction: column; gap: 18px; margin-top: 20px; }
198
+ .file-info {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 12px;
202
+ padding: 14px 18px;
203
+ background: var(--surface-2);
204
+ border: 1px solid var(--border);
205
+ border-radius: var(--radius-sm);
206
+ }
207
+ .file-icon { font-size: 28px; }
208
+ .file-meta { flex: 1; display: flex; flex-direction: column; }
209
+ .file-name { font-weight: 600; font-size: 14px; color: var(--text); word-break: break-all; }
210
+ .file-size { font-size: 12px; color: var(--text-sub); }
211
+ .remove-btn {
212
+ background: none;
213
+ border: 1px solid var(--border);
214
+ color: var(--text-sub);
215
+ width: 32px; height: 32px;
216
+ border-radius: 50%;
217
+ font-size: 14px;
218
+ cursor: pointer;
219
+ transition: all var(--transition);
220
+ display: flex; align-items: center; justify-content: center;
221
+ }
222
+ .remove-btn:hover { background: rgba(239,68,68,0.1); border-color: var(--red); color: var(--red); }
223
+
224
+ .video-container {
225
+ border-radius: var(--radius-sm);
226
+ overflow: hidden;
227
+ background: #000;
228
+ border: 1px solid var(--border);
229
+ }
230
+ .video-preview { width: 100%; max-height: 300px; display: block; }
231
+
232
+ .analyze-btn {
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: center;
236
+ gap: 10px;
237
+ width: 100%;
238
+ padding: 16px;
239
+ font-size: 16px;
240
+ font-weight: 700;
241
+ border: none;
242
+ border-radius: var(--radius-sm);
243
+ background: linear-gradient(135deg, var(--purple), var(--cyan));
244
+ color: #fff;
245
+ cursor: pointer;
246
+ transition: all var(--transition);
247
+ letter-spacing: 0.3px;
248
+ box-shadow: 0 8px 24px rgba(139,92,246,0.35);
249
+ }
250
+ .analyze-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 32px rgba(139,92,246,0.45); }
251
+ .analyze-btn:active { transform: translateY(0); }
252
+ .analyze-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
253
+ .btn-icon { font-size: 18px; }
254
+
255
+ /* ── Loading ── */
256
+ .loading-section {
257
+ display: flex;
258
+ flex-direction: column;
259
+ align-items: center;
260
+ gap: 24px;
261
+ padding: 40px 0;
262
+ }
263
+ .loading-animation {
264
+ position: relative;
265
+ width: 100px;
266
+ height: 100px;
267
+ display: flex;
268
+ align-items: center;
269
+ justify-content: center;
270
+ }
271
+ .spinner-ring {
272
+ position: absolute;
273
+ inset: 0;
274
+ border-radius: 50%;
275
+ border: 3px solid transparent;
276
+ border-top-color: var(--purple);
277
+ animation: spin 1s linear infinite;
278
+ }
279
+ .ring-2 {
280
+ inset: 8px;
281
+ border-top-color: var(--cyan);
282
+ animation-duration: 1.4s;
283
+ animation-direction: reverse;
284
+ }
285
+ .ring-3 {
286
+ inset: 16px;
287
+ border-top-color: rgba(139,92,246,0.4);
288
+ animation-duration: 2s;
289
+ }
290
+ .spinner-center { font-size: 28px; z-index: 1; }
291
+ @keyframes spin { to { transform: rotate(360deg); } }
292
+
293
+ .loading-title { font-size: 22px; font-weight: 700; text-align: center; }
294
+ .loading-steps { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 280px; }
295
+ .step {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 12px;
299
+ font-size: 14px;
300
+ color: var(--text-sub);
301
+ transition: all var(--transition);
302
+ }
303
+ .step.active { color: var(--text); }
304
+ .step-dot {
305
+ width: 8px; height: 8px;
306
+ border-radius: 50%;
307
+ background: var(--border);
308
+ transition: all var(--transition);
309
+ flex-shrink: 0;
310
+ }
311
+ .step.active .step-dot {
312
+ background: var(--purple);
313
+ box-shadow: 0 0 8px var(--purple);
314
+ animation: pulse-dot 1s ease-in-out infinite;
315
+ }
316
+ .step.done .step-dot { background: var(--green); animation: none; }
317
+ .step.done { color: var(--green); }
318
+ @keyframes pulse-dot { 0%,100% { transform: scale(1); } 50% { transform: scale(1.4); } }
319
+ .loading-note { font-size: 12px; color: var(--text-sub); text-align: center; opacity: 0.7; }
320
+
321
+ /* ── Results ── */
322
+ .results-section { display: flex; flex-direction: column; gap: 24px; }
323
+
324
+ .verdict-card {
325
+ display: flex;
326
+ gap: 32px;
327
+ padding: 28px;
328
+ border-radius: var(--radius);
329
+ border: 1px solid var(--border-glow);
330
+ background: var(--surface-2);
331
+ align-items: center;
332
+ flex-wrap: wrap;
333
+ }
334
+ .verdict-card.is-fake { border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.04); }
335
+ .verdict-card.is-real { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.04); }
336
+
337
+ .verdict-left {
338
+ display: flex;
339
+ flex-direction: column;
340
+ align-items: center;
341
+ gap: 10px;
342
+ min-width: 160px;
343
+ }
344
+ .verdict-circle-wrap { position: relative; width: 140px; height: 140px; }
345
+ .verdict-ring { width: 100%; height: 100%; transform: rotate(-90deg); }
346
+ .ring-bg { fill: none; stroke: rgba(255,255,255,0.06); stroke-width: 8; }
347
+ .ring-progress {
348
+ fill: none;
349
+ stroke: var(--purple);
350
+ stroke-width: 8;
351
+ stroke-linecap: round;
352
+ stroke-dasharray: 314;
353
+ stroke-dashoffset: 314;
354
+ transition: stroke-dashoffset 1.2s cubic-bezier(0.4,0,0.2,1), stroke 0.4s;
355
+ }
356
+ .verdict-inner {
357
+ position: absolute;
358
+ inset: 0;
359
+ display: flex;
360
+ flex-direction: column;
361
+ align-items: center;
362
+ justify-content: center;
363
+ }
364
+ .verdict-pct {
365
+ font-family: 'Space Grotesk', sans-serif;
366
+ font-size: 30px;
367
+ font-weight: 700;
368
+ line-height: 1;
369
+ }
370
+ .verdict-label {
371
+ font-size: 13px;
372
+ font-weight: 700;
373
+ letter-spacing: 2px;
374
+ text-transform: uppercase;
375
+ margin-top: 2px;
376
+ }
377
+ .verdict-desc { font-size: 12px; color: var(--text-sub); text-align: center; }
378
+
379
+ .verdict-right { flex: 1; min-width: 220px; }
380
+ .verdict-badge {
381
+ display: inline-block;
382
+ font-size: 20px;
383
+ font-weight: 800;
384
+ font-family: 'Space Grotesk', sans-serif;
385
+ padding: 8px 20px;
386
+ border-radius: var(--radius-sm);
387
+ margin-bottom: 18px;
388
+ letter-spacing: 1px;
389
+ }
390
+ .verdict-badge.fake {
391
+ background: rgba(239,68,68,0.15);
392
+ border: 1px solid rgba(239,68,68,0.4);
393
+ color: #f87171;
394
+ }
395
+ .verdict-badge.real {
396
+ background: rgba(34,197,94,0.15);
397
+ border: 1px solid rgba(34,197,94,0.4);
398
+ color: #4ade80;
399
+ }
400
+
401
+ .verdict-stats { display: flex; flex-direction: column; gap: 10px; }
402
+ .stat-row {
403
+ display: flex;
404
+ justify-content: space-between;
405
+ align-items: center;
406
+ padding: 10px 14px;
407
+ border-radius: 8px;
408
+ background: var(--surface);
409
+ border: 1px solid var(--border);
410
+ font-size: 14px;
411
+ }
412
+ .stat-label { color: var(--text-sub); }
413
+ .stat-val { font-weight: 700; }
414
+ .fake-val { color: #f87171; }
415
+ .real-val { color: #4ade80; }
416
+
417
+ /* ── Frame Chart ── */
418
+ .chart-card {
419
+ padding: 24px;
420
+ background: var(--surface-2);
421
+ border: 1px solid var(--border);
422
+ border-radius: var(--radius);
423
+ }
424
+ .chart-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
425
+ .chart-sub { font-size: 12px; color: var(--text-sub); margin-bottom: 18px; }
426
+ .frame-chart {
427
+ display: flex;
428
+ align-items: flex-end;
429
+ gap: 4px;
430
+ height: 120px;
431
+ position: relative;
432
+ padding: 0 4px;
433
+ }
434
+ .frame-chart::after {
435
+ content: "50%";
436
+ position: absolute;
437
+ right: 0;
438
+ top: 50%;
439
+ transform: translateY(-50%);
440
+ font-size: 10px;
441
+ color: var(--text-sub);
442
+ opacity: 0.6;
443
+ }
444
+ .frame-chart::before {
445
+ content: "";
446
+ position: absolute;
447
+ left: 4px; right: 24px;
448
+ top: 50%;
449
+ border-top: 1px dashed rgba(255,255,255,0.12);
450
+ }
451
+ .bar-wrap {
452
+ flex: 1;
453
+ display: flex;
454
+ align-items: flex-end;
455
+ height: 100%;
456
+ position: relative;
457
+ }
458
+ .bar {
459
+ width: 100%;
460
+ border-radius: 4px 4px 0 0;
461
+ min-height: 4px;
462
+ transition: height 0.8s cubic-bezier(0.4,0,0.2,1);
463
+ cursor: default;
464
+ position: relative;
465
+ }
466
+ .bar::after {
467
+ content: attr(data-tip);
468
+ position: absolute;
469
+ bottom: calc(100% + 4px);
470
+ left: 50%;
471
+ transform: translateX(-50%);
472
+ background: rgba(0,0,0,0.85);
473
+ color: var(--text);
474
+ font-size: 10px;
475
+ padding: 2px 6px;
476
+ border-radius: 4px;
477
+ white-space: nowrap;
478
+ opacity: 0;
479
+ pointer-events: none;
480
+ transition: opacity 0.2s;
481
+ }
482
+ .bar:hover::after { opacity: 1; }
483
+ .bar.bar-fake { background: linear-gradient(to top, #ef4444, #f97316); }
484
+ .bar.bar-real { background: linear-gradient(to top, #22c55e, #06B6D4); }
485
+
486
+ .chart-legend {
487
+ display: flex;
488
+ gap: 18px;
489
+ margin-top: 12px;
490
+ }
491
+ .legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-sub); }
492
+ .dot { width: 10px; height: 10px; border-radius: 3px; }
493
+ .dot-fake { background: #ef4444; }
494
+ .dot-real { background: #22c55e; }
495
+ .dot-thresh { background: transparent; border: 1px dashed rgba(255,255,255,0.3); }
496
+
497
+ /* ── Actions ── */
498
+ .result-actions { display: flex; gap: 12px; flex-wrap: wrap; }
499
+ .action-btn {
500
+ flex: 1;
501
+ min-width: 160px;
502
+ padding: 14px 20px;
503
+ font-size: 15px;
504
+ font-weight: 600;
505
+ border-radius: var(--radius-sm);
506
+ cursor: pointer;
507
+ transition: all var(--transition);
508
+ border: none;
509
+ outline: none;
510
+ letter-spacing: 0.2px;
511
+ }
512
+ .action-primary {
513
+ background: linear-gradient(135deg, var(--purple), var(--cyan));
514
+ color: #fff;
515
+ box-shadow: 0 6px 20px rgba(139,92,246,0.3);
516
+ }
517
+ .action-primary:hover { transform: translateY(-2px); box-shadow: 0 10px 28px rgba(139,92,246,0.4); }
518
+ .action-secondary {
519
+ background: var(--surface-2);
520
+ color: var(--text);
521
+ border: 1px solid var(--border);
522
+ }
523
+ .action-secondary:hover { background: var(--surface); border-color: var(--purple); }
524
+
525
+ /* ── Error ── */
526
+ .error-section {
527
+ display: flex;
528
+ flex-direction: column;
529
+ align-items: center;
530
+ gap: 16px;
531
+ padding: 40px 0;
532
+ text-align: center;
533
+ }
534
+ .error-icon { font-size: 48px; }
535
+ .error-title { font-size: 22px; font-weight: 700; color: var(--red); }
536
+ .error-msg { color: var(--text-sub); max-width: 400px; font-size: 14px; }
537
+
538
+ /* ── Footer ── */
539
+ .footer {
540
+ text-align: center;
541
+ padding: 28px 24px 40px;
542
+ color: var(--text-sub);
543
+ font-size: 13px;
544
+ position: relative;
545
+ z-index: 1;
546
+ line-height: 1.8;
547
+ }
548
+ .footer-note { font-size: 11px; opacity: 0.5; }
549
+
550
+ /* ── Utility ── */
551
+ .hidden { display: none !important; }
552
+
553
+ /* ── Responsive ── */
554
+ @media (max-width: 600px) {
555
+ .main-card { padding: 24px 16px; border-radius: 20px; margin: 0 12px 40px; }
556
+ .verdict-card { flex-direction: column; align-items: center; text-align: center; }
557
+ .verdict-right { width: 100%; }
558
+ .hero { padding: 40px 16px 24px; }
559
+ .navbar { padding: 12px 16px; }
560
+ .result-actions { flex-direction: column; }
561
+ }
562
+
563
+ /* ── Entrance Animation ── */
564
+ @keyframes fadeUp {
565
+ from { opacity: 0; transform: translateY(20px); }
566
+ to { opacity: 1; transform: translateY(0); }
567
+ }
568
+ .results-section > * {
569
+ animation: fadeUp 0.5s ease both;
570
+ }
571
+ .results-section > *:nth-child(2) { animation-delay: 0.1s; }
572
+ .results-section > *:nth-child(3) { animation-delay: 0.2s; }