Spaces:
Sleeping
Sleeping
| """Deepfake Detector - Hugging Face ZeroGPU Space. | |
| V-JEPA 2.1 ViT-Giant + AttentiveClassifier probe. | |
| Reference version for VigilVid: | |
| - Uses window-vote aggregation instead of max-window final scoring. | |
| - Does not store submitted user videos after processing. | |
| """ | |
| import glob | |
| import os | |
| import shutil | |
| import tempfile | |
| import threading | |
| import time | |
| import warnings | |
| import requests | |
| warnings.filterwarnings("ignore", category=FutureWarning) | |
| warnings.filterwarnings("ignore", category=UserWarning) | |
| import gradio as gr | |
| import spaces | |
| import torch | |
| from huggingface_hub import hf_hub_download | |
| from inference_core import AttentiveClassifier, get_video_windows | |
| from saverapi_client import saverapi_fetch | |
| # --------------------------------------------------------------------------- | |
| # Config and secrets | |
| # --------------------------------------------------------------------------- | |
| DEVICE = "cuda" | |
| FEATURE_DIM = 1408 | |
| HF_TOKEN = os.environ.get("HF_TOKEN") | |
| ENCODER_REPO = os.environ.get("ENCODER_REPO", "") | |
| PROBE_REPO = os.environ.get("PROBE_REPO", ENCODER_REPO) | |
| # SaverAPI rotator config. The client itself lives in saverapi_client.py. | |
| SAVER_API_KEY_COOLDOWN_SEC = int(os.environ.get("SAVER_API_KEY_COOLDOWN_SEC", "3600")) | |
| SAVER_API_MAX_RETRIES_PER_REQUEST = int( | |
| os.environ.get("SAVER_API_MAX_RETRIES_PER_REQUEST", "5") | |
| ) | |
| SAVER_API_MAX_FILESIZE_MB = int(os.environ.get("SAVER_API_MAX_FILESIZE_MB", "100")) | |
| SAVER_API_STARTUP_PROBE_URL = os.environ.get("SAVER_API_STARTUP_PROBE_URL", "").strip() | |
| # --------------------------------------------------------------------------- | |
| # Model loading | |
| # --------------------------------------------------------------------------- | |
| print("=" * 50) | |
| print("Loading V-JEPA 2.1 encoder architecture via torch.hub...") | |
| encoder, _ = torch.hub.load( | |
| "facebookresearch/vjepa2", | |
| "vjepa2_1_vit_giant_384", | |
| pretrained=False, | |
| trust_repo=True, | |
| verbose=False, | |
| ) | |
| print(f"Downloading V-JEPA 2.1 weights from {ENCODER_REPO} ...") | |
| encoder_weights_path = hf_hub_download( | |
| repo_id=ENCODER_REPO, | |
| filename="vjepa2_1_vitg_384.pt", | |
| token=HF_TOKEN, | |
| ) | |
| ckpt = torch.load(encoder_weights_path, map_location="cpu", weights_only=True) | |
| ckpt = ckpt.get("ema_encoder", ckpt.get("encoder", ckpt)) | |
| ckpt = {k.replace("module.", "").replace("backbone.", ""): v for k, v in ckpt.items()} | |
| encoder.load_state_dict(ckpt, strict=False) | |
| del ckpt | |
| encoder = encoder.to(DEVICE).to(torch.bfloat16) | |
| encoder.eval() | |
| print("Encoder ready.") | |
| print("Loading AttentiveClassifier probe...") | |
| probe_path = hf_hub_download( | |
| repo_id=PROBE_REPO, | |
| filename="attentive_probe_optimized.pt", | |
| token=HF_TOKEN, | |
| ) | |
| probe_state = torch.load(probe_path, map_location="cpu") | |
| if "probe_state_dict" in probe_state: | |
| probe_state = probe_state["probe_state_dict"] | |
| probe = AttentiveClassifier(embed_dim=FEATURE_DIM) | |
| probe.load_state_dict(probe_state) | |
| probe = probe.to(DEVICE) | |
| probe.eval() | |
| print("Probe ready.") | |
| print("=" * 50) | |
| # --------------------------------------------------------------------------- | |
| # Helper utilities | |
| # --------------------------------------------------------------------------- | |
| def clamp_probability(value: float) -> float: | |
| return min(max(float(value), 0.0), 1.0) | |
| def aggregate_window_vote_probability(scores: list[dict]) -> dict[str, float | int]: | |
| """Blend average score with majority voting to avoid max-window bias.""" | |
| probabilities = [ | |
| clamp_probability(score["prob"]) | |
| for score in scores | |
| if isinstance(score, dict) and "prob" in score | |
| ] | |
| if not probabilities: | |
| return { | |
| "final_probability": 0.0, | |
| "mean_probability": 0.0, | |
| "fake_vote_ratio": 0.0, | |
| "fake_vote_count": 0, | |
| "peak_probability": 0.0, | |
| } | |
| mean_probability = sum(probabilities) / len(probabilities) | |
| fake_vote_count = sum(1 for probability in probabilities if probability >= 0.5) | |
| fake_vote_ratio = fake_vote_count / len(probabilities) | |
| final_probability = (mean_probability + fake_vote_ratio) / 2 | |
| return { | |
| "final_probability": clamp_probability(final_probability), | |
| "mean_probability": mean_probability, | |
| "fake_vote_ratio": fake_vote_ratio, | |
| "fake_vote_count": fake_vote_count, | |
| "peak_probability": max(probabilities), | |
| } | |
| def remove_video_files(video_path: str) -> None: | |
| for file_path in glob.glob(video_path + "*") + [video_path]: | |
| if os.path.exists(file_path): | |
| try: | |
| os.remove(file_path) | |
| except Exception: | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # SaverAPI key rotator | |
| # --------------------------------------------------------------------------- | |
| class KeyRotator: | |
| """Sticky-failover pool of SaverAPI keys. | |
| Reads keys from SAVER_API_KEY_1..SAVER_API_KEY_10. On any error, the | |
| failing key is marked dead for `cooldown_sec` seconds. State is in-memory | |
| only and resets on Space restart. | |
| """ | |
| KEY_ENV_NAMES = [f"SAVER_API_KEY_{index}" for index in range(1, 11)] | |
| def __init__(self, cooldown_sec: int): | |
| self.keys = [os.environ.get(name, "").strip() for name in self.KEY_ENV_NAMES] | |
| self.keys = [key for key in self.keys if key] | |
| self.cooldown_sec = cooldown_sec | |
| self._dead_until: dict = {} | |
| self._lock = threading.Lock() | |
| self._cursor = 0 | |
| def get_key(self) -> str | None: | |
| with self._lock: | |
| if not self.keys: | |
| return None | |
| key_count = len(self.keys) | |
| for offset in range(key_count): | |
| index = (self._cursor + offset) % key_count | |
| key = self.keys[index] | |
| if self._dead_until.get(key, 0) <= time.time(): | |
| return key | |
| return None | |
| def mark_dead(self, key: str) -> None: | |
| with self._lock: | |
| self._dead_until[key] = time.time() + self.cooldown_sec | |
| try: | |
| self._cursor = (self.keys.index(key) + 1) % len(self.keys) | |
| except (ValueError, ZeroDivisionError): | |
| pass | |
| def status(self) -> dict: | |
| with self._lock: | |
| now = time.time() | |
| return { | |
| "configured": len(self.keys), | |
| "live": sum( | |
| 1 for key in self.keys if self._dead_until.get(key, 0) <= now | |
| ), | |
| "dead": [ | |
| self.mask(key) | |
| for key, timestamp in self._dead_until.items() | |
| if timestamp > now | |
| ], | |
| "cursor_index": self._cursor, | |
| } | |
| def mask(key: str) -> str: | |
| if len(key) <= 10: | |
| return "***" | |
| return f"{key[:6]}***{key[-4:]}" | |
| key_rotator = KeyRotator(cooldown_sec=SAVER_API_KEY_COOLDOWN_SEC) | |
| print(f"[SaverAPI] Rotator initialised with {len(key_rotator.keys)} key(s).") | |
| def _saverapi_fetch_one(url: str, key: str) -> tuple[bool, str]: | |
| """Normalise saverapi_fetch return to (ok, file_or_error).""" | |
| try: | |
| return saverapi_fetch(url, key, max_filesize_mb=SAVER_API_MAX_FILESIZE_MB) | |
| except Exception as exc: | |
| return False, f"key-level:exception: {type(exc).__name__}: {exc}" | |
| def download_video(url: str) -> tuple[bool, str]: | |
| """Tries configured SaverAPI keys in sequence.""" | |
| if not url.lower().startswith(("http://", "https://")): | |
| return False, "Unsupported URL scheme. Use http:// or https://" | |
| last_err = "" | |
| for attempt in range(SAVER_API_MAX_RETRIES_PER_REQUEST): | |
| key = key_rotator.get_key() | |
| if key is None: | |
| return False, "SaverAPI key pool exhausted; try again later." | |
| ok, payload = _saverapi_fetch_one(url, key) | |
| if ok: | |
| return True, payload | |
| last_err = payload | |
| if last_err.startswith("key-level:"): | |
| key_rotator.mark_dead(key) | |
| print( | |
| f"[SaverAPI] key {KeyRotator.mask(key)} failed " | |
| f"(attempt {attempt + 1}/{SAVER_API_MAX_RETRIES_PER_REQUEST}): " | |
| f"{last_err[:200]}" | |
| ) | |
| continue | |
| return False, last_err | |
| return ( | |
| False, | |
| f"SaverAPI: all {SAVER_API_MAX_RETRIES_PER_REQUEST} retries failed. " | |
| f"Last: {last_err[:200]}", | |
| ) | |
| def _startup_probe(): | |
| """Best-effort probe of each configured key at Space boot.""" | |
| if os.environ.get("SKIP_STARTUP_PROBE", "").lower() in ("1", "true", "yes"): | |
| print("[SaverAPI] Startup probe skipped (SKIP_STARTUP_PROBE=1).") | |
| return | |
| if not SAVER_API_STARTUP_PROBE_URL: | |
| print("[SaverAPI] No SAVER_API_STARTUP_PROBE_URL set; skipping startup probe.") | |
| return | |
| if not key_rotator.keys: | |
| print("[SaverAPI] No keys configured; skipping startup probe.") | |
| return | |
| print( | |
| f"[SaverAPI] Probing {len(key_rotator.keys)} key(s) " | |
| f"with {SAVER_API_STARTUP_PROBE_URL} ..." | |
| ) | |
| for key in key_rotator.keys: | |
| ok, payload = _saverapi_fetch_one(SAVER_API_STARTUP_PROBE_URL, key) | |
| if ok: | |
| remove_video_files(payload) | |
| print(f"[SaverAPI] key {KeyRotator.mask(key)}: OK") | |
| continue | |
| if payload.startswith("key-level:"): | |
| key_rotator.mark_dead(key) | |
| print(f"[SaverAPI] key {KeyRotator.mask(key)}: DEAD ({payload[:100]})") | |
| else: | |
| print( | |
| f"[SaverAPI] key {KeyRotator.mask(key)}: " | |
| f"probe-URL error ({payload[:100]})" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # GPU inference | |
| # --------------------------------------------------------------------------- | |
| def encode_and_classify(windows: list) -> list: | |
| gpu_start_time = time.time() | |
| print("\n[TIMING] --- GPU Allocated & Inference Started ---") | |
| if not windows: | |
| return [] | |
| vram_bytes = torch.cuda.get_device_properties(DEVICE).total_memory | |
| vram_gb = vram_bytes / (1024**3) | |
| dynamic_batch_size = max(3, int(vram_gb / 3)) | |
| all_clips = [] | |
| window_clip_counts = [] | |
| for window in windows: | |
| all_clips.extend(window["clips"]) | |
| window_clip_counts.append(len(window["clips"])) | |
| global_batch = torch.stack(all_clips).to(DEVICE) | |
| all_feats = [] | |
| with torch.no_grad(): | |
| with torch.amp.autocast("cuda", dtype=torch.bfloat16): | |
| for index in range(0, global_batch.shape[0], dynamic_batch_size): | |
| chunk = global_batch[index : index + dynamic_batch_size] | |
| feats = encoder(chunk) | |
| all_feats.append(feats) | |
| all_feats = torch.cat(all_feats, dim=0) | |
| scores = [] | |
| current_idx = 0 | |
| for index, window in enumerate(windows): | |
| count = window_clip_counts[index] | |
| window_feats = all_feats[current_idx : current_idx + count] | |
| current_idx += count | |
| video_feats = window_feats.view(1, -1, FEATURE_DIM) | |
| with torch.no_grad(): | |
| with torch.amp.autocast("cuda", dtype=torch.bfloat16): | |
| logit, _ = probe(video_feats) | |
| prob = torch.sigmoid(logit).item() | |
| scores.append( | |
| { | |
| "prob": prob, | |
| "start_sec": window["start_sec"], | |
| "end_sec": window["end_sec"], | |
| } | |
| ) | |
| gpu_end_time = time.time() | |
| print(f"[TIMING] GPU execution completed in {gpu_end_time - gpu_start_time:.2f}s") | |
| return scores | |
| # --------------------------------------------------------------------------- | |
| # Gradio predict | |
| # --------------------------------------------------------------------------- | |
| def predict( | |
| url: str, | |
| uploaded_video: str, | |
| current_state: dict, | |
| ) -> tuple[str, str, str, dict]: | |
| global_start_time = time.time() | |
| print(f"\n{'=' * 30}\n[TIMING] Request Received.") | |
| url = (url or "").strip() | |
| if current_state and "video_path" in current_state: | |
| remove_video_files(current_state["video_path"]) | |
| file_start_time = time.time() | |
| if uploaded_video: | |
| tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) | |
| shutil.copy(uploaded_video, tmp.name) | |
| video_path = tmp.name | |
| elif url: | |
| ok, path_or_err = download_video(url) | |
| if not ok: | |
| return f"Download failed: {path_or_err}", "", "", {} | |
| video_path = path_or_err | |
| else: | |
| return "Please enter a URL or upload a video.", "", "", {} | |
| file_ready_time = time.time() | |
| print( | |
| "[TIMING] File Acquisition (Download/Copy) took: " | |
| f"{file_ready_time - file_start_time:.2f}s" | |
| ) | |
| try: | |
| windows, total_frames, fps, duration_sec = get_video_windows(video_path) | |
| except Exception as exc: | |
| remove_video_files(video_path) | |
| return f"Video decode failed: {exc}", "", "", {} | |
| cpu_decode_time = time.time() | |
| print(f"[TIMING] CPU Video Decoding took: {cpu_decode_time - file_ready_time:.2f}s") | |
| print("[TIMING] Requesting GPU Hardware from ZeroGPU Queue...") | |
| queue_start_time = time.time() | |
| scores = encode_and_classify(windows) | |
| queue_and_gpu_time = time.time() | |
| print( | |
| "[TIMING] Total time spent requesting queue + GPU execution: " | |
| f"{queue_and_gpu_time - queue_start_time:.2f}s" | |
| ) | |
| if not scores: | |
| remove_video_files(video_path) | |
| return "No windows processed.", "", "", {} | |
| aggregation = aggregate_window_vote_probability(scores) | |
| final_prob = float(aggregation["final_probability"]) | |
| peak_prob = float(aggregation["peak_probability"]) | |
| label = "AI-GENERATED (FAKE)" if final_prob > 0.5 else "REAL" | |
| elapsed_time = time.time() - global_start_time | |
| confidence = f"{final_prob * 100:.1f}%" | |
| lines = [ | |
| f"Processing Time: {elapsed_time:.1f}s", | |
| f"Duration: {duration_sec:.1f}s | Frames: {total_frames} | Windows: {len(scores)}", | |
| ( | |
| f"Aggregation: mean={float(aggregation['mean_probability']) * 100:.1f}% | " | |
| f"fake votes={int(aggregation['fake_vote_count'])}/{len(scores)} | " | |
| f"peak={peak_prob * 100:.1f}%\n" | |
| ), | |
| ] | |
| for score in scores: | |
| flag = " <- strongest window" if score["prob"] == peak_prob else "" | |
| lines.append( | |
| f" [{score['start_sec']:.1f}s - {score['end_sec']:.1f}s] -> " | |
| f"{score['prob'] * 100:.1f}% fake{flag}" | |
| ) | |
| remove_video_files(video_path) | |
| print(f"[TIMING] Total Blocking Time Before UI Return: {elapsed_time:.2f}s") | |
| return label, confidence, "\n".join(lines), {} | |
| # --------------------------------------------------------------------------- | |
| # Gradio UI | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(title="Deepfake Detector") as demo: | |
| session_state = gr.State(value={}) | |
| gr.Markdown( | |
| """ | |
| ## Deepfake Video Detector | |
| Paste a public Instagram, TikTok, or direct `.mp4` URL, **OR** upload a video directly. | |
| Uses **V-JEPA 2.1 ViT-Giant** + **AttentiveClassifier** probe. | |
| """ | |
| ) | |
| with gr.Row(): | |
| url_box = gr.Textbox( | |
| label="Video URL", | |
| placeholder="https://www.instagram.com/reels/...", | |
| autofocus=True, | |
| scale=1, | |
| ) | |
| upload_box = gr.Video( | |
| label="Or Upload Video directly", | |
| sources=["upload"], | |
| scale=1, | |
| ) | |
| run_btn = gr.Button("Analyze", variant="primary") | |
| with gr.Row(): | |
| label_out = gr.Textbox(label="Prediction", scale=2) | |
| conf_out = gr.Textbox(label="AI-generated probability", scale=1) | |
| breakdown_out = gr.Textbox( | |
| label="Window Breakdown & Processing Time", | |
| lines=7, | |
| interactive=False, | |
| ) | |
| run_btn.click( | |
| fn=predict, | |
| inputs=[url_box, upload_box, session_state], | |
| outputs=[label_out, conf_out, breakdown_out, session_state], | |
| ) | |
| url_box.submit( | |
| fn=predict, | |
| inputs=[url_box, upload_box, session_state], | |
| outputs=[label_out, conf_out, breakdown_out, session_state], | |
| ) | |
| _startup_probe() | |
| demo.launch() | |