Chethanand commited on
Commit
eb3afa1
·
verified ·
1 Parent(s): 3cabb4e

Upload 24 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ # Install system dependencies required for OpenCV
4
+ RUN apt-get update && apt-get install -y \
5
+ libgl1-mesa-glx \
6
+ libglib2.0-0 \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ # Set up a new user named "user" with user ID 1000
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV HOME=/home/user \
13
+ PATH=/home/user/.local/bin:$PATH
14
+
15
+ WORKDIR $HOME/app
16
+
17
+ # Copy the requirements file and install dependencies
18
+ COPY --chown=user requirements_web.txt .
19
+ RUN pip install --no-cache-dir -r requirements_web.txt gunicorn
20
+
21
+ # Copy the rest of the application
22
+ COPY --chown=user . .
23
+
24
+ # Expose port 7860 (Hugging Face Spaces default port)
25
+ EXPOSE 7860
26
+
27
+ # Command to run the Flask application using Gunicorn
28
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "--timeout", "120", "--workers", "1", "--threads", "2", "app:app"]
api/__init__.py ADDED
File without changes
api/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (137 Bytes). View file
 
api/__pycache__/predict.cpython-310.pyc ADDED
Binary file (2.81 kB). View file
 
api/predict.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import tempfile
5
+ import uuid
6
+
7
+ import torch
8
+ import numpy as np
9
+ from flask import Blueprint, request, jsonify, current_app
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ from config import config
14
+ from utils.video_utils import load_video_frames, normalize_frames, validate_video
15
+
16
+ predict_bp = Blueprint("predict", __name__)
17
+
18
+ ALLOWED_EXTENSIONS = {".mp4", ".avi", ".mkv", ".mov", ".webm"}
19
+
20
+
21
+ def _allowed_file(filename: str) -> bool:
22
+ return os.path.splitext(filename.lower())[1] in ALLOWED_EXTENSIONS
23
+
24
+
25
+ @predict_bp.route("/predict", methods=["POST"])
26
+ def predict():
27
+ # ── Validate upload ───────────────────────────────────────────────────────
28
+ if "video" not in request.files:
29
+ return jsonify({"error": "No video file provided"}), 400
30
+
31
+ file = request.files["video"]
32
+ if file.filename == "":
33
+ return jsonify({"error": "Empty filename"}), 400
34
+
35
+ if not _allowed_file(file.filename):
36
+ exts = ", ".join(ALLOWED_EXTENSIONS)
37
+ return jsonify({"error": f"Unsupported format. Allowed: {exts}"}), 415
38
+
39
+ # ── Save to temp path ─────────────────────────────────────────────────────
40
+ suffix = os.path.splitext(file.filename)[1]
41
+ tmp_path = os.path.join(tempfile.gettempdir(), f"aivd_{uuid.uuid4().hex}{suffix}")
42
+ try:
43
+ file.save(tmp_path)
44
+ except Exception as e:
45
+ return jsonify({"error": f"Could not save file: {e}"}), 500
46
+
47
+ # ── Inference ─────────────────────────────────────────────────────────────
48
+ t_start = time.time()
49
+ try:
50
+ model = current_app.model
51
+ device = current_app.device
52
+
53
+ if not validate_video(tmp_path):
54
+ return jsonify({"error": "Invalid or corrupted video file"}), 422
55
+
56
+ frames = load_video_frames(
57
+ tmp_path,
58
+ num_frames=config.FRAMES_PER_VIDEO,
59
+ frame_size=(config.FRAME_HEIGHT, config.FRAME_WIDTH),
60
+ frame_skip=config.FRAME_SKIP,
61
+ )
62
+ if frames is None:
63
+ return jsonify({"error": "Could not extract frames from video"}), 422
64
+
65
+ frames = normalize_frames(frames)
66
+
67
+ frames_tensor = torch.from_numpy(frames).float()
68
+ frames_tensor = frames_tensor.permute(0, 3, 1, 2) # (T, 3, H, W)
69
+ frames_tensor = frames_tensor.unsqueeze(0) # (1, T, 3, H, W)
70
+ frames_tensor = frames_tensor.to(device)
71
+
72
+ with torch.no_grad():
73
+ output = model(frames_tensor)
74
+ prob = torch.sigmoid(output).item()
75
+
76
+ threshold = config.PREDICTION_THRESHOLD
77
+ is_ai = prob > threshold
78
+ verdict = "AI GENERATED" if is_ai else "REAL VIDEO"
79
+ confidence = prob if is_ai else (1.0 - prob)
80
+ elapsed = round(time.time() - t_start, 2)
81
+
82
+ return jsonify({
83
+ "verdict": verdict,
84
+ "is_ai": is_ai,
85
+ "probability": round(prob, 6),
86
+ "confidence": round(confidence * 100, 2),
87
+ "processing_time": elapsed,
88
+ "threshold": threshold,
89
+ })
90
+
91
+ except Exception as e:
92
+ return jsonify({"error": str(e)}), 500
93
+
94
+ finally:
95
+ try:
96
+ os.remove(tmp_path)
97
+ except OSError:
98
+ pass
app.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import torch
4
+ from flask import Flask, send_from_directory, jsonify
5
+ from flask_cors import CORS
6
+
7
+ # ── Project root on sys.path so existing modules resolve ──────────────────────
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ from config import config
11
+ from models.model import ResNetLSTM
12
+
13
+ # ── Flask setup ───────────────────────────────────────────────────────────────
14
+ app = Flask(__name__, static_folder="static", static_url_path="")
15
+ CORS(app)
16
+
17
+ # ── Load model once at startup ────────────────────────────────────────────────
18
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
19
+ model = ResNetLSTM()
20
+
21
+ _model_path = os.path.join(
22
+ os.path.dirname(os.path.abspath(__file__)),
23
+ "models", "checkpoints", "last_checkpoint.pth"
24
+ )
25
+
26
+ print(f"[startup] Loading model from {_model_path} …")
27
+ _checkpoint = torch.load(_model_path, map_location=device, weights_only=False)
28
+ if "model_state_dict" in _checkpoint:
29
+ model.load_state_dict(_checkpoint["model_state_dict"])
30
+ else:
31
+ model.load_state_dict(_checkpoint)
32
+ model.to(device)
33
+ model.eval()
34
+ print(f"[startup] Model ready on {device}")
35
+
36
+ # ── Attach model / device to app context so blueprints can use them ───────────
37
+ app.model = model
38
+ app.device = device
39
+
40
+ # ── Register API blueprint ────────────────────────────────────────────────────
41
+ from api.predict import predict_bp
42
+ app.register_blueprint(predict_bp, url_prefix="/api")
43
+
44
+ # ── Serve SPA ─────────────────────────────────────────────────────────────────
45
+ @app.route("/", defaults={"path": ""})
46
+ @app.route("/<path:path>")
47
+ def serve(path):
48
+ if path and os.path.exists(os.path.join(app.static_folder, path)):
49
+ return send_from_directory(app.static_folder, path)
50
+ return send_from_directory(app.static_folder, "index.html")
51
+
52
+
53
+ @app.route("/api/status")
54
+ def status():
55
+ return jsonify({
56
+ "status": "ok",
57
+ "device": str(device),
58
+ "threshold": config.PREDICTION_THRESHOLD,
59
+ })
60
+
61
+
62
+ if __name__ == "__main__":
63
+ app.run(debug=False, host="0.0.0.0", port=5000)
config/__init__.py ADDED
File without changes
config/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (132 Bytes). View file
 
config/__pycache__/config.cpython-310.pyc ADDED
Binary file (543 Bytes). View file
 
config/config.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+
4
+ # Paths
5
+ # Paths
6
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
7
+ DATASET_DIR = os.path.join(BASE_DIR, 'dataset')
8
+ AI_VIDEO_DIR = os.path.join(DATASET_DIR, 'ai')
9
+ REAL_VIDEO_DIR = os.path.join(DATASET_DIR, 'real')
10
+
11
+ # Data config
12
+ FRAMES_PER_VIDEO = 30 # Increased to capture more temporal info, or keep small if memory constraint
13
+ FRAME_HEIGHT = 224
14
+ FRAME_WIDTH = 224
15
+ FRAME_SKIP = 5 # Sample every 5th frame to cover more time
16
+
17
+ # Training config
18
+ BATCH_SIZE = 4 # Reduced to avoid OOM
19
+ LEARNING_RATE = 1e-4
20
+ NUM_EPOCHS = 20
21
+
22
+ # Inference config
23
+ PREDICTION_THRESHOLD = 0.4539
models/__init__.py ADDED
File without changes
models/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (132 Bytes). View file
 
models/__pycache__/model.cpython-310.pyc ADDED
Binary file (1.31 kB). View file
 
models/checkpoints/best_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd0eee56178feb1ebc326021d0df92d3116ee6e9d33069282a1a6d5c0fbcbfb6
3
+ size 50047564
models/checkpoints/last_checkpoint.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f3da65722ae81b846e9aba0c33a79b7e475a78388cb30cf0d143bca226ca4682
3
+ size 150042045
models/model.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import torch
3
+ import torch.nn as nn
4
+ import torchvision.models as models
5
+
6
+ class ResNetLSTM(nn.Module):
7
+ def __init__(self, num_classes=1, hidden_size=256, num_layers=2):
8
+ super(ResNetLSTM, self).__init__()
9
+
10
+ # Load Pretrained ResNet
11
+ resnet = models.resnet18(pretrained=True)
12
+
13
+ # Remove last fully connected layer
14
+ # ResNet18 fc input size is 512
15
+ modules = list(resnet.children())[:-1]
16
+ self.resnet = nn.Sequential(*modules)
17
+
18
+ # Freeze ResNet params (Optional: unfreeze later or partial unfreeze)
19
+ # For now, let's fine-tune all or freeze?
20
+ # Fine-tuning is usually better if we have enough data.
21
+ # User has ~4k videos, which is decent. Let's NOT freeze.
22
+
23
+ self.lstm = nn.LSTM(
24
+ input_size=512,
25
+ hidden_size=hidden_size,
26
+ num_layers=num_layers,
27
+ batch_first=True,
28
+ dropout=0.5
29
+ )
30
+
31
+ self.fc = nn.Linear(hidden_size, num_classes)
32
+ self.sigmoid = nn.Sigmoid() # For binary classification logic if needed manually, but we use BCEWithLogitsLoss
33
+
34
+ def forward(self, x):
35
+ # x shape: (Batch, Frames, Channels, Height, Width)
36
+ # Example: (B, 30, 3, 224, 224)
37
+
38
+ b, t, c, h, w = x.size()
39
+
40
+ # Flatten batch and frames for CNN processing
41
+ # (B*T, 3, 224, 224)
42
+ c_in = x.view(b * t, c, h, w)
43
+
44
+ # CNN Feature Extraction
45
+ # Output: (B*T, 512, 1, 1) -> squeeze -> (B*T, 512)
46
+ features = self.resnet(c_in)
47
+ features = features.view(features.size(0), -1)
48
+
49
+ # Reshape back to (B, T, Features)
50
+ r_in = features.view(b, t, -1)
51
+
52
+ # LSTM Temporal processing
53
+ # Output: (B, T, Hidden)
54
+ # hidden/cell: (Layers, B, Hidden)
55
+ lstm_out, _ = self.lstm(r_in)
56
+
57
+ # Take the output from the last time step
58
+ # (B, Hidden)
59
+ last_out = lstm_out[:, -1, :]
60
+
61
+ # Classification
62
+ out = self.fc(last_out)
63
+
64
+ return out
requirements_web.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ flask>=2.3
2
+ flask-cors>=4.0
3
+ torch
4
+ torchvision
5
+ opencv-python
6
+ numpy
static/css/style.css ADDED
@@ -0,0 +1,691 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════
2
+ AI VIDEO DETECTOR – Premium Dark UI
3
+ ═══════════════════════════════════════════════════════════ */
4
+
5
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap');
6
+
7
+ /* ── Reset & Base ─────────────────────────────────────────── */
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg-base: #060912;
12
+ --bg-surface: #0d1220;
13
+ --bg-card: rgba(15, 22, 40, 0.85);
14
+ --bg-card-hover: rgba(20, 30, 56, 0.92);
15
+
16
+ --accent-blue: #4f8ef7;
17
+ --accent-cyan: #00d4ff;
18
+ --accent-purple: #a855f7;
19
+ --accent-green: #22d3a0;
20
+ --accent-red: #f44c6a;
21
+ --accent-orange: #f97316;
22
+
23
+ --text-primary: #e8edf8;
24
+ --text-secondary: #8892aa;
25
+ --text-muted: #4a5568;
26
+
27
+ --border: rgba(79, 142, 247, 0.18);
28
+ --border-glow: rgba(0, 212, 255, 0.35);
29
+ --glass: rgba(255, 255, 255, 0.04);
30
+
31
+ --radius-sm: 8px;
32
+ --radius-md: 14px;
33
+ --radius-lg: 20px;
34
+ --radius-xl: 28px;
35
+
36
+ --shadow-card: 0 8px 40px rgba(0,0,0,0.5), 0 0 0 1px var(--border);
37
+ --shadow-glow: 0 0 40px rgba(79, 142, 247, 0.2);
38
+ --shadow-ai: 0 0 60px rgba(248, 71, 107, 0.25);
39
+ --shadow-real: 0 0 60px rgba(34, 211, 160, 0.25);
40
+
41
+ --transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
42
+ }
43
+
44
+ html { scroll-behavior: smooth; }
45
+
46
+ body {
47
+ font-family: 'Inter', system-ui, sans-serif;
48
+ background: var(--bg-base);
49
+ color: var(--text-primary);
50
+ min-height: 100vh;
51
+ overflow-x: hidden;
52
+ line-height: 1.6;
53
+ }
54
+
55
+ /* ── Animated Background ──────────────────────────────────── */
56
+ .bg-canvas {
57
+ position: fixed;
58
+ inset: 0;
59
+ z-index: 0;
60
+ pointer-events: none;
61
+ overflow: hidden;
62
+ }
63
+ .bg-canvas::before {
64
+ content: '';
65
+ position: absolute;
66
+ width: 700px; height: 700px;
67
+ top: -200px; left: -150px;
68
+ background: radial-gradient(circle, rgba(79,142,247,0.12) 0%, transparent 70%);
69
+ animation: driftA 18s ease-in-out infinite alternate;
70
+ }
71
+ .bg-canvas::after {
72
+ content: '';
73
+ position: absolute;
74
+ width: 600px; height: 600px;
75
+ bottom: -150px; right: -100px;
76
+ background: radial-gradient(circle, rgba(168,85,247,0.10) 0%, transparent 70%);
77
+ animation: driftB 22s ease-in-out infinite alternate;
78
+ }
79
+ .bg-orb {
80
+ position: absolute;
81
+ width: 400px; height: 400px;
82
+ top: 45%; left: 55%;
83
+ background: radial-gradient(circle, rgba(0,212,255,0.07) 0%, transparent 65%);
84
+ animation: driftC 14s ease-in-out infinite alternate;
85
+ }
86
+
87
+ @keyframes driftA { to { transform: translate(80px, 60px); } }
88
+ @keyframes driftB { to { transform: translate(-70px, -50px); } }
89
+ @keyframes driftC { to { transform: translate(-60px, 80px); } }
90
+
91
+ /* ── Layout ───────────────────────────────────────────────── */
92
+ .page-wrap {
93
+ position: relative;
94
+ z-index: 1;
95
+ max-width: 1100px;
96
+ margin: 0 auto;
97
+ padding: 0 24px 80px;
98
+ }
99
+
100
+ /* ── Navbar ───────────────────────────────────────────────── */
101
+ .navbar {
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ padding: 24px 0 20px;
106
+ border-bottom: 1px solid var(--border);
107
+ margin-bottom: 0;
108
+ }
109
+ .nav-brand {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 12px;
113
+ }
114
+ .nav-logo {
115
+ width: 38px; height: 38px;
116
+ background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
117
+ border-radius: 10px;
118
+ display: flex; align-items: center; justify-content: center;
119
+ font-size: 18px;
120
+ box-shadow: 0 0 20px rgba(79,142,247,0.4);
121
+ }
122
+ .nav-title {
123
+ font-size: 1.1rem;
124
+ font-weight: 700;
125
+ letter-spacing: -0.02em;
126
+ background: linear-gradient(135deg, var(--text-primary), var(--accent-cyan));
127
+ -webkit-background-clip: text;
128
+ -webkit-text-fill-color: transparent;
129
+ background-clip: text;
130
+ }
131
+ .nav-badge {
132
+ font-size: 0.7rem;
133
+ font-weight: 600;
134
+ color: var(--accent-cyan);
135
+ border: 1px solid var(--accent-cyan);
136
+ border-radius: 20px;
137
+ padding: 2px 10px;
138
+ letter-spacing: 0.05em;
139
+ text-transform: uppercase;
140
+ opacity: 0.8;
141
+ }
142
+ .nav-status {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 8px;
146
+ font-size: 0.82rem;
147
+ color: var(--text-secondary);
148
+ }
149
+ .status-dot {
150
+ width: 8px; height: 8px;
151
+ border-radius: 50%;
152
+ background: var(--accent-green);
153
+ box-shadow: 0 0 10px var(--accent-green);
154
+ animation: pulse-dot 2s ease-in-out infinite;
155
+ }
156
+ .status-dot.offline { background: var(--accent-red); box-shadow: 0 0 10px var(--accent-red); }
157
+ @keyframes pulse-dot {
158
+ 0%, 100% { opacity: 1; transform: scale(1); }
159
+ 50% { opacity: 0.6; transform: scale(0.85); }
160
+ }
161
+
162
+ /* ── Hero ─────────────────────────────────────────────────── */
163
+ .hero {
164
+ text-align: center;
165
+ padding: 52px 0 40px;
166
+ animation: fadeUp 0.7s ease both;
167
+ }
168
+ .hero-eyebrow {
169
+ display: inline-flex;
170
+ align-items: center;
171
+ gap: 8px;
172
+ font-size: 0.75rem;
173
+ font-weight: 600;
174
+ letter-spacing: 0.12em;
175
+ text-transform: uppercase;
176
+ color: var(--accent-cyan);
177
+ background: rgba(0,212,255,0.08);
178
+ border: 1px solid rgba(0,212,255,0.2);
179
+ border-radius: 20px;
180
+ padding: 6px 16px;
181
+ margin-bottom: 24px;
182
+ }
183
+ .hero-title {
184
+ font-size: clamp(2.4rem, 5vw, 3.8rem);
185
+ font-weight: 900;
186
+ letter-spacing: -0.04em;
187
+ line-height: 1.05;
188
+ margin-bottom: 18px;
189
+ }
190
+ .hero-title .gradient-text {
191
+ background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-cyan) 50%, var(--accent-purple) 100%);
192
+ -webkit-background-clip: text;
193
+ -webkit-text-fill-color: transparent;
194
+ background-clip: text;
195
+ }
196
+ .hero-sub {
197
+ font-size: 1.05rem;
198
+ color: var(--text-secondary);
199
+ max-width: 480px;
200
+ margin: 0 auto 0;
201
+ line-height: 1.7;
202
+ }
203
+
204
+ /* ── Main Detection Card ──────────────────────────────────── */
205
+ .detector-card {
206
+ background: var(--bg-card);
207
+ border: 1px solid var(--border);
208
+ border-radius: var(--radius-xl);
209
+ padding: 40px;
210
+ box-shadow: var(--shadow-card);
211
+ backdrop-filter: blur(16px);
212
+ -webkit-backdrop-filter: blur(16px);
213
+ animation: fadeUp 0.7s 0.15s ease both;
214
+ }
215
+
216
+ /* ── Upload Zone ──────────────────────────────────────────── */
217
+ .upload-zone {
218
+ border: 2px dashed var(--border);
219
+ border-radius: var(--radius-lg);
220
+ padding: 52px 32px;
221
+ text-align: center;
222
+ cursor: pointer;
223
+ transition: var(--transition);
224
+ background: rgba(255,255,255,0.02);
225
+ position: relative;
226
+ overflow: hidden;
227
+ }
228
+ .upload-zone::before {
229
+ content: '';
230
+ position: absolute;
231
+ inset: 0;
232
+ background: linear-gradient(135deg, rgba(79,142,247,0.05), rgba(0,212,255,0.03));
233
+ opacity: 0;
234
+ transition: var(--transition);
235
+ }
236
+ .upload-zone:hover, .upload-zone.drag-over {
237
+ border-color: var(--accent-cyan);
238
+ box-shadow: 0 0 30px rgba(0,212,255,0.15), inset 0 0 30px rgba(0,212,255,0.05);
239
+ transform: translateY(-2px);
240
+ }
241
+ .upload-zone:hover::before, .upload-zone.drag-over::before { opacity: 1; }
242
+ .upload-zone.drag-over {
243
+ border-color: var(--accent-blue);
244
+ animation: borderPulse 1s ease infinite;
245
+ }
246
+ @keyframes borderPulse {
247
+ 0%, 100% { box-shadow: 0 0 20px rgba(79,142,247,0.25), inset 0 0 20px rgba(79,142,247,0.08); }
248
+ 50% { box-shadow: 0 0 40px rgba(79,142,247,0.45), inset 0 0 40px rgba(79,142,247,0.12); }
249
+ }
250
+ .upload-icon {
251
+ width: 64px; height: 64px;
252
+ margin: 0 auto 20px;
253
+ background: linear-gradient(135deg, rgba(79,142,247,0.15), rgba(0,212,255,0.1));
254
+ border-radius: 18px;
255
+ display: flex; align-items: center; justify-content: center;
256
+ font-size: 28px;
257
+ border: 1px solid rgba(79,142,247,0.25);
258
+ transition: var(--transition);
259
+ }
260
+ .upload-zone:hover .upload-icon {
261
+ background: linear-gradient(135deg, rgba(79,142,247,0.25), rgba(0,212,255,0.18));
262
+ transform: scale(1.08);
263
+ box-shadow: 0 0 24px rgba(0,212,255,0.3);
264
+ }
265
+ .upload-title {
266
+ font-size: 1.15rem;
267
+ font-weight: 600;
268
+ color: var(--text-primary);
269
+ margin-bottom: 8px;
270
+ }
271
+ .upload-sub {
272
+ font-size: 0.85rem;
273
+ color: var(--text-muted);
274
+ margin-bottom: 24px;
275
+ }
276
+ .upload-formats {
277
+ display: flex;
278
+ gap: 8px;
279
+ justify-content: center;
280
+ flex-wrap: wrap;
281
+ }
282
+ .fmt-tag {
283
+ font-size: 0.72rem;
284
+ font-weight: 600;
285
+ color: var(--accent-blue);
286
+ background: rgba(79,142,247,0.1);
287
+ border: 1px solid rgba(79,142,247,0.2);
288
+ border-radius: 6px;
289
+ padding: 3px 10px;
290
+ font-family: 'JetBrains Mono', monospace;
291
+ }
292
+ #file-input { display: none; }
293
+
294
+ /* ── Video Preview ────────────────────────────────────────── */
295
+ .preview-area {
296
+ display: none;
297
+ margin-top: 28px;
298
+ border-radius: var(--radius-md);
299
+ overflow: hidden;
300
+ border: 1px solid var(--border);
301
+ background: #000;
302
+ position: relative;
303
+ }
304
+ .preview-area.visible { display: block; animation: fadeUp 0.4s ease both; }
305
+ #preview-video {
306
+ width: 100%;
307
+ max-height: 340px;
308
+ object-fit: contain;
309
+ display: block;
310
+ }
311
+ .preview-info {
312
+ display: flex;
313
+ align-items: center;
314
+ justify-content: space-between;
315
+ padding: 12px 16px;
316
+ background: rgba(255,255,255,0.03);
317
+ border-top: 1px solid var(--border);
318
+ }
319
+ .preview-name {
320
+ font-size: 0.85rem;
321
+ font-weight: 500;
322
+ color: var(--text-primary);
323
+ white-space: nowrap;
324
+ overflow: hidden;
325
+ text-overflow: ellipsis;
326
+ max-width: 60%;
327
+ }
328
+ .preview-meta {
329
+ font-size: 0.78rem;
330
+ color: var(--text-muted);
331
+ font-family: 'JetBrains Mono', monospace;
332
+ }
333
+ .preview-remove {
334
+ background: rgba(244,76,106,0.15);
335
+ border: 1px solid rgba(244,76,106,0.3);
336
+ color: var(--accent-red);
337
+ border-radius: 6px;
338
+ padding: 4px 12px;
339
+ font-size: 0.78rem;
340
+ font-weight: 600;
341
+ cursor: pointer;
342
+ transition: var(--transition);
343
+ }
344
+ .preview-remove:hover {
345
+ background: rgba(244,76,106,0.28);
346
+ box-shadow: 0 0 14px rgba(244,76,106,0.25);
347
+ }
348
+
349
+ /* ── Analyze Button ───────────────────────────────────────── */
350
+ .btn-analyze {
351
+ display: flex;
352
+ align-items: center;
353
+ justify-content: center;
354
+ gap: 10px;
355
+ width: 100%;
356
+ margin-top: 28px;
357
+ padding: 17px 32px;
358
+ font-size: 1rem;
359
+ font-weight: 700;
360
+ font-family: 'Inter', sans-serif;
361
+ letter-spacing: -0.01em;
362
+ color: #fff;
363
+ background: linear-gradient(135deg, var(--accent-blue) 0%, var(--accent-cyan) 100%);
364
+ border: none;
365
+ border-radius: var(--radius-md);
366
+ cursor: pointer;
367
+ transition: var(--transition);
368
+ position: relative;
369
+ overflow: hidden;
370
+ box-shadow: 0 4px 24px rgba(79,142,247,0.35);
371
+ }
372
+ .btn-analyze::before {
373
+ content: '';
374
+ position: absolute;
375
+ inset: 0;
376
+ background: linear-gradient(135deg, rgba(255,255,255,0.15), transparent);
377
+ opacity: 0;
378
+ transition: var(--transition);
379
+ }
380
+ .btn-analyze:hover:not(:disabled) {
381
+ transform: translateY(-2px);
382
+ box-shadow: 0 8px 36px rgba(79,142,247,0.5);
383
+ }
384
+ .btn-analyze:hover:not(:disabled)::before { opacity: 1; }
385
+ .btn-analyze:active:not(:disabled) { transform: translateY(0); }
386
+ .btn-analyze:disabled {
387
+ opacity: 0.5;
388
+ cursor: not-allowed;
389
+ }
390
+ .btn-spinner {
391
+ width: 18px; height: 18px;
392
+ border: 2px solid rgba(255,255,255,0.3);
393
+ border-top-color: #fff;
394
+ border-radius: 50%;
395
+ animation: spin 0.7s linear infinite;
396
+ display: none;
397
+ }
398
+ .btn-analyze.loading .btn-spinner { display: block; }
399
+ .btn-analyze.loading .btn-label { opacity: 0.7; }
400
+ @keyframes spin { to { transform: rotate(360deg); } }
401
+
402
+ /* ── Progress Bar ─────────────────────────────────────────── */
403
+ .progress-wrap {
404
+ margin-top: 22px;
405
+ display: none;
406
+ }
407
+ .progress-wrap.visible { display: block; animation: fadeUp 0.3s ease both; }
408
+ .progress-header {
409
+ display: flex;
410
+ justify-content: space-between;
411
+ font-size: 0.8rem;
412
+ color: var(--text-secondary);
413
+ margin-bottom: 8px;
414
+ }
415
+ .progress-bar-track {
416
+ height: 5px;
417
+ background: rgba(255,255,255,0.06);
418
+ border-radius: 999px;
419
+ overflow: hidden;
420
+ }
421
+ .progress-bar-fill {
422
+ height: 100%;
423
+ background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
424
+ border-radius: 999px;
425
+ width: 0%;
426
+ transition: width 0.4s ease;
427
+ box-shadow: 0 0 12px rgba(0,212,255,0.5);
428
+ }
429
+ .progress-steps {
430
+ display: flex;
431
+ gap: 6px;
432
+ margin-top: 12px;
433
+ flex-wrap: wrap;
434
+ }
435
+ .pstep {
436
+ font-size: 0.72rem;
437
+ color: var(--text-muted);
438
+ background: rgba(255,255,255,0.04);
439
+ border-radius: 20px;
440
+ padding: 3px 10px;
441
+ transition: var(--transition);
442
+ }
443
+ .pstep.active {
444
+ color: var(--accent-cyan);
445
+ background: rgba(0,212,255,0.1);
446
+ box-shadow: 0 0 12px rgba(0,212,255,0.2);
447
+ }
448
+ .pstep.done {
449
+ color: var(--accent-green);
450
+ background: rgba(34,211,160,0.1);
451
+ }
452
+
453
+ /* ── Results Panel ────────────────────────────────────────── */
454
+ .results-panel {
455
+ display: none;
456
+ margin-top: 32px;
457
+ }
458
+ .results-panel.visible { display: block; animation: fadeUp 0.5s ease both; }
459
+
460
+ .result-card {
461
+ border-radius: var(--radius-lg);
462
+ padding: 36px;
463
+ border: 1px solid var(--border);
464
+ background: var(--bg-card);
465
+ backdrop-filter: blur(12px);
466
+ }
467
+ .result-card.ai-result {
468
+ border-color: rgba(244,76,106,0.35);
469
+ box-shadow: var(--shadow-ai);
470
+ }
471
+ .result-card.real-result {
472
+ border-color: rgba(34,211,160,0.35);
473
+ box-shadow: var(--shadow-real);
474
+ }
475
+
476
+ .result-layout {
477
+ display: grid;
478
+ grid-template-columns: auto 1fr;
479
+ gap: 36px;
480
+ align-items: center;
481
+ }
482
+
483
+ /* Gauge */
484
+ .gauge-wrap {
485
+ display: flex;
486
+ flex-direction: column;
487
+ align-items: center;
488
+ gap: 12px;
489
+ }
490
+ .gauge-svg { width: 160px; height: 160px; }
491
+ .gauge-track {
492
+ fill: none;
493
+ stroke: rgba(255,255,255,0.06);
494
+ stroke-width: 12;
495
+ }
496
+ .gauge-fill {
497
+ fill: none;
498
+ stroke-width: 12;
499
+ stroke-linecap: round;
500
+ transition: stroke-dashoffset 1.2s cubic-bezier(0.4,0,0.2,1);
501
+ }
502
+ .gauge-fill.ai-fill { stroke: url(#gaugeGradAI); }
503
+ .gauge-fill.real-fill { stroke: url(#gaugeGradReal); }
504
+ .gauge-center {
505
+ font-family: 'JetBrains Mono', monospace;
506
+ font-size: 1.3rem;
507
+ font-weight: 700;
508
+ fill: var(--text-primary);
509
+ }
510
+ .gauge-label-svg {
511
+ font-size: 0.6rem;
512
+ fill: var(--text-muted);
513
+ text-transform: uppercase;
514
+ letter-spacing: 0.1em;
515
+ }
516
+
517
+ /* Verdict Info */
518
+ .verdict-badge {
519
+ display: inline-flex;
520
+ align-items: center;
521
+ gap: 10px;
522
+ padding: 10px 22px;
523
+ border-radius: var(--radius-md);
524
+ font-size: 1.2rem;
525
+ font-weight: 800;
526
+ letter-spacing: -0.02em;
527
+ margin-bottom: 18px;
528
+ border: 1px solid;
529
+ }
530
+ .verdict-badge.ai-badge {
531
+ color: var(--accent-red);
532
+ background: rgba(244,76,106,0.1);
533
+ border-color: rgba(244,76,106,0.3);
534
+ }
535
+ .verdict-badge.real-badge {
536
+ color: var(--accent-green);
537
+ background: rgba(34,211,160,0.1);
538
+ border-color: rgba(34,211,160,0.3);
539
+ }
540
+ .verdict-icon { font-size: 1.5rem; }
541
+
542
+ .result-metrics {
543
+ display: grid;
544
+ grid-template-columns: repeat(2, 1fr);
545
+ gap: 14px;
546
+ margin-bottom: 18px;
547
+ }
548
+ .metric-item {
549
+ background: rgba(255,255,255,0.03);
550
+ border: 1px solid var(--border);
551
+ border-radius: var(--radius-sm);
552
+ padding: 14px;
553
+ }
554
+ .metric-label {
555
+ font-size: 0.72rem;
556
+ color: var(--text-muted);
557
+ text-transform: uppercase;
558
+ letter-spacing: 0.08em;
559
+ margin-bottom: 6px;
560
+ }
561
+ .metric-value {
562
+ font-size: 1.05rem;
563
+ font-weight: 700;
564
+ font-family: 'JetBrains Mono', monospace;
565
+ color: var(--text-primary);
566
+ }
567
+ .metric-value.green { color: var(--accent-green); }
568
+ .metric-value.red { color: var(--accent-red); }
569
+ .metric-value.blue { color: var(--accent-blue); }
570
+
571
+ .result-desc {
572
+ font-size: 0.88rem;
573
+ color: var(--text-secondary);
574
+ line-height: 1.65;
575
+ padding: 14px;
576
+ background: rgba(255,255,255,0.025);
577
+ border-radius: var(--radius-sm);
578
+ border-left: 3px solid;
579
+ }
580
+ .result-desc.ai { border-color: var(--accent-red); }
581
+ .result-desc.real{ border-color: var(--accent-green); }
582
+
583
+ .btn-reset {
584
+ display: inline-flex;
585
+ align-items: center;
586
+ gap: 8px;
587
+ margin-top: 20px;
588
+ padding: 10px 22px;
589
+ font-size: 0.87rem;
590
+ font-weight: 600;
591
+ font-family: 'Inter', sans-serif;
592
+ color: var(--text-secondary);
593
+ background: rgba(255,255,255,0.05);
594
+ border: 1px solid var(--border);
595
+ border-radius: var(--radius-sm);
596
+ cursor: pointer;
597
+ transition: var(--transition);
598
+ }
599
+ .btn-reset:hover {
600
+ color: var(--text-primary);
601
+ background: rgba(255,255,255,0.09);
602
+ border-color: rgba(255,255,255,0.2);
603
+ }
604
+
605
+
606
+ /* ── Footer ───────────────────────────────────────────────── */
607
+ footer {
608
+ margin-top: 64px;
609
+ padding-top: 28px;
610
+ border-top: 1px solid var(--border);
611
+ display: flex;
612
+ align-items: center;
613
+ justify-content: space-between;
614
+ flex-wrap: wrap;
615
+ gap: 12px;
616
+ font-size: 0.8rem;
617
+ color: var(--text-muted);
618
+ }
619
+ .footer-brand {
620
+ font-weight: 700;
621
+ color: var(--text-secondary);
622
+ }
623
+
624
+ /* ── Toast Notifications ──────────────────────────────────── */
625
+ .toast-container {
626
+ position: fixed;
627
+ top: 24px;
628
+ right: 24px;
629
+ z-index: 9999;
630
+ display: flex;
631
+ flex-direction: column;
632
+ gap: 10px;
633
+ }
634
+ .toast {
635
+ display: flex;
636
+ align-items: center;
637
+ gap: 12px;
638
+ padding: 14px 18px;
639
+ border-radius: var(--radius-md);
640
+ backdrop-filter: blur(20px);
641
+ -webkit-backdrop-filter: blur(20px);
642
+ font-size: 0.87rem;
643
+ font-weight: 500;
644
+ max-width: 360px;
645
+ animation: slideInRight 0.3s ease;
646
+ border: 1px solid;
647
+ }
648
+ .toast.error {
649
+ background: rgba(244,76,106,0.15);
650
+ border-color: rgba(244,76,106,0.35);
651
+ color: #ffaab8;
652
+ }
653
+ .toast.success {
654
+ background: rgba(34,211,160,0.12);
655
+ border-color: rgba(34,211,160,0.3);
656
+ color: #7fffd4;
657
+ }
658
+ .toast.info {
659
+ background: rgba(79,142,247,0.12);
660
+ border-color: rgba(79,142,247,0.3);
661
+ color: #aaccff;
662
+ }
663
+ .toast-icon { font-size: 1.1rem; }
664
+ .toast-exit { animation: slideOutRight 0.3s ease forwards; }
665
+ @keyframes slideInRight {
666
+ from { transform: translateX(110%); opacity: 0; }
667
+ to { transform: translateX(0); opacity: 1; }
668
+ }
669
+ @keyframes slideOutRight {
670
+ from { transform: translateX(0); opacity: 1; }
671
+ to { transform: translateX(110%); opacity: 0; }
672
+ }
673
+
674
+ /* ── Animations ───────────────────────────────────────────── */
675
+ @keyframes fadeUp {
676
+ from { opacity: 0; transform: translateY(24px); }
677
+ to { opacity: 1; transform: translateY(0); }
678
+ }
679
+
680
+ /* ── Responsive ───────────────────────────────────────────── */
681
+ @media (max-width: 700px) {
682
+ .detector-card { padding: 24px 18px; }
683
+ .result-layout { grid-template-columns: 1fr; }
684
+ .gauge-wrap { margin: 0 auto; }
685
+ .result-metrics { grid-template-columns: 1fr 1fr; }
686
+ .navbar { flex-direction: column; align-items: flex-start; gap: 14px; }
687
+ }
688
+ @media (max-width: 480px) {
689
+ .page-wrap { padding: 0 14px 60px; }
690
+ .result-metrics { grid-template-columns: 1fr; }
691
+ }
static/index.html ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>AI Video Detector – Instant Video Authenticity Check</title>
7
+ <meta name="description" content="Instantly detect whether a video is AI-generated or real. Upload and get your result in seconds." />
8
+ <link rel="stylesheet" href="/css/style.css?v=3" />
9
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>" />
10
+ </head>
11
+ <body>
12
+
13
+ <!-- Animated background -->
14
+ <div class="bg-canvas" aria-hidden="true">
15
+ <div class="bg-orb"></div>
16
+ </div>
17
+
18
+ <!-- Toast container -->
19
+ <div class="toast-container" id="toast-container" aria-live="polite"></div>
20
+
21
+ <!-- SVG Gradient Defs (hidden) -->
22
+ <svg width="0" height="0" style="position:absolute">
23
+ <defs>
24
+ <linearGradient id="gaugeGradAI" x1="0%" y1="0%" x2="100%" y2="0%">
25
+ <stop offset="0%" stop-color="#f44c6a"/>
26
+ <stop offset="100%" stop-color="#f97316"/>
27
+ </linearGradient>
28
+ <linearGradient id="gaugeGradReal" x1="0%" y1="0%" x2="100%" y2="0%">
29
+ <stop offset="0%" stop-color="#22d3a0"/>
30
+ <stop offset="100%" stop-color="#4f8ef7"/>
31
+ </linearGradient>
32
+ </defs>
33
+ </svg>
34
+
35
+ <div class="page-wrap">
36
+
37
+ <!-- ── Navbar ──────────────────────────────────────────────── -->
38
+ <nav class="navbar" role="navigation" aria-label="Main navigation">
39
+ <div class="nav-brand">
40
+ <div class="nav-logo" aria-hidden="true">🔍</div>
41
+ <span class="nav-title">AI Video Detector</span>
42
+ </div>
43
+ <div class="nav-status" id="nav-status" aria-live="polite">
44
+ <div class="status-dot offline" id="status-dot"></div>
45
+ <span id="status-text">Connecting…</span>
46
+ </div>
47
+ </nav>
48
+
49
+ <!-- ── Hero ────────────────────────────────────────────────── -->
50
+ <header class="hero" role="banner">
51
+ <h1 class="hero-title">
52
+ Is this video <span class="gradient-text">AI Generated?</span>
53
+ </h1>
54
+ <p class="hero-sub">
55
+ Upload any video and get an instant authenticity verdict.
56
+ Powered by deep learning — no sign-up required.
57
+ </p>
58
+ </header>
59
+
60
+ <!-- ── Detector Card ────────────────────────────────────────── -->
61
+ <main class="detector-card" id="detector-main" role="main" aria-label="Video detection interface">
62
+
63
+ <!-- Upload Zone -->
64
+ <div
65
+ class="upload-zone"
66
+ id="upload-zone"
67
+ role="button"
68
+ tabindex="0"
69
+ aria-label="Upload video file. Click or drag and drop."
70
+ aria-dropeffect="copy"
71
+ >
72
+ <div class="upload-icon" aria-hidden="true">🎬</div>
73
+ <p class="upload-title">Drop your video here</p>
74
+ <p class="upload-sub">or click to browse files</p>
75
+ <div class="upload-formats" aria-label="Supported formats">
76
+ <span class="fmt-tag">.MP4</span>
77
+ <span class="fmt-tag">.AVI</span>
78
+ <span class="fmt-tag">.MKV</span>
79
+ <span class="fmt-tag">.MOV</span>
80
+ <span class="fmt-tag">.WEBM</span>
81
+ </div>
82
+ </div>
83
+
84
+ <input
85
+ type="file"
86
+ id="file-input"
87
+ accept=".mp4,.avi,.mkv,.mov,.webm,video/*"
88
+ aria-label="Select video file"
89
+ />
90
+
91
+ <!-- Video Preview -->
92
+ <div class="preview-area" id="preview-area" aria-label="Video preview">
93
+ <video id="preview-video" controls preload="metadata" aria-label="Preview of uploaded video"></video>
94
+ <div class="preview-info">
95
+ <span class="preview-name" id="preview-name"></span>
96
+ <span class="preview-meta" id="preview-meta"></span>
97
+ <button class="preview-remove" id="btn-remove" aria-label="Remove selected video">✕ Remove</button>
98
+ </div>
99
+ </div>
100
+
101
+ <!-- Analyze Button -->
102
+ <button
103
+ class="btn-analyze"
104
+ id="btn-analyze"
105
+ disabled
106
+ aria-label="Analyze video for AI generation"
107
+ aria-busy="false"
108
+ >
109
+ <div class="btn-spinner" aria-hidden="true"></div>
110
+ <span class="btn-label">⚡ Analyze Video</span>
111
+ </button>
112
+
113
+ <!-- Progress -->
114
+ <div class="progress-wrap" id="progress-wrap" aria-label="Analysis progress" aria-live="polite">
115
+ <div class="progress-header">
116
+ <span id="progress-label">Analyzing…</span>
117
+ <span id="progress-pct">0%</span>
118
+ </div>
119
+ <div class="progress-bar-track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="Analysis progress">
120
+ <div class="progress-bar-fill" id="progress-bar"></div>
121
+ </div>
122
+ <div class="progress-steps">
123
+ <span class="pstep" id="ps-upload">📤 Uploading</span>
124
+ <span class="pstep" id="ps-extract">🎞️ Reading Video</span>
125
+ <span class="pstep" id="ps-cnn">🧠 Analyzing</span>
126
+ <span class="pstep" id="ps-lstm">⏱️ Processing</span>
127
+ <span class="pstep" id="ps-verdict">⚖️ Verdict</span>
128
+ </div>
129
+ </div>
130
+
131
+ <!-- Results Panel -->
132
+ <section class="results-panel" id="results-panel" aria-label="Analysis results" aria-live="polite">
133
+ <div class="result-card" id="result-card">
134
+ <div class="result-layout">
135
+
136
+ <!-- Gauge -->
137
+ <div class="gauge-wrap" aria-hidden="true">
138
+ <svg class="gauge-svg" viewBox="0 0 160 160" id="gauge-svg">
139
+ <circle class="gauge-track" cx="80" cy="80" r="64"
140
+ stroke-dasharray="330" stroke-dashoffset="0"
141
+ transform="rotate(-230 80 80)" />
142
+ <circle class="gauge-fill" id="gauge-fill"
143
+ cx="80" cy="80" r="64"
144
+ stroke-dasharray="330" stroke-dashoffset="330"
145
+ transform="rotate(-230 80 80)" />
146
+ <text class="gauge-center" id="gauge-pct-text"
147
+ x="80" y="75" text-anchor="middle" dominant-baseline="middle">--</text>
148
+ <text class="gauge-label-svg"
149
+ x="80" y="95" text-anchor="middle" dominant-baseline="middle">Confidence</text>
150
+ </svg>
151
+ </div>
152
+
153
+ <!-- Info -->
154
+ <div class="result-info">
155
+ <div class="verdict-badge" id="verdict-badge" role="status" aria-live="polite">
156
+ <span class="verdict-icon" id="verdict-icon"></span>
157
+ <span id="verdict-text"></span>
158
+ </div>
159
+
160
+ <div class="result-metrics" role="list">
161
+ <div class="metric-item" role="listitem">
162
+ <div class="metric-label">Confidence Score</div>
163
+ <div class="metric-value" id="m-conf"></div>
164
+ </div>
165
+ <div class="metric-item" role="listitem">
166
+ <div class="metric-label">AI Probability</div>
167
+ <div class="metric-value" id="m-prob"></div>
168
+ </div>
169
+ <div class="metric-item" role="listitem">
170
+ <div class="metric-label">Processing Time</div>
171
+ <div class="metric-value blue" id="m-time"></div>
172
+ </div>
173
+ <div class="metric-item" role="listitem">
174
+ <div class="metric-label">Result</div>
175
+ <div class="metric-value" id="m-thresh"></div>
176
+ </div>
177
+ </div>
178
+
179
+ <p class="result-desc" id="result-desc" role="note"></p>
180
+
181
+ <button class="btn-reset" id="btn-reset" aria-label="Analyze another video">
182
+ ↩ Check Another Video
183
+ </button>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ </section>
188
+
189
+ </main>
190
+
191
+ <!-- ── Footer ───────────────────────────────────────────────── -->
192
+ <footer role="contentinfo">
193
+ <span class="footer-brand">AI Video Detector</span>
194
+ <span>© 2025 · All rights reserved</span>
195
+ </footer>
196
+
197
+ </div><!-- /page-wrap -->
198
+
199
+ <script src="/js/app.js?v=3"></script>
200
+ </body>
201
+ </html>
static/js/app.js ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AI Video Detector – Frontend Application
3
+ * Handles: drag-drop upload, video preview, API call, animated gauge, results
4
+ */
5
+
6
+ "use strict";
7
+
8
+ // ── DOM References ────────────────────────────────────────────────────────────
9
+ const uploadZone = document.getElementById("upload-zone");
10
+ const fileInput = document.getElementById("file-input");
11
+ const previewArea = document.getElementById("preview-area");
12
+ const previewVideo = document.getElementById("preview-video");
13
+ const previewName = document.getElementById("preview-name");
14
+ const previewMeta = document.getElementById("preview-meta");
15
+ const btnRemove = document.getElementById("btn-remove");
16
+ const btnAnalyze = document.getElementById("btn-analyze");
17
+ const progressWrap = document.getElementById("progress-wrap");
18
+ const progressBar = document.getElementById("progress-bar");
19
+ const progressLbl = document.getElementById("progress-label");
20
+ const progressPct = document.getElementById("progress-pct");
21
+ const resultsPanel = document.getElementById("results-panel");
22
+ const resultCard = document.getElementById("result-card");
23
+ const verdictBadge = document.getElementById("verdict-badge");
24
+ const verdictIcon = document.getElementById("verdict-icon");
25
+ const verdictText = document.getElementById("verdict-text");
26
+ const gaugeFill = document.getElementById("gauge-fill");
27
+ const gaugePctTxt = document.getElementById("gauge-pct-text");
28
+ const mProb = document.getElementById("m-prob");
29
+ const mConf = document.getElementById("m-conf");
30
+ const mTime = document.getElementById("m-time");
31
+ const mThresh = document.getElementById("m-thresh");
32
+ const resultDesc = document.getElementById("result-desc");
33
+ const btnReset = document.getElementById("btn-reset");
34
+ const statusDot = document.getElementById("status-dot");
35
+ const statusText = document.getElementById("status-text");
36
+ const toastCont = document.getElementById("toast-container");
37
+
38
+ // Progress steps
39
+ const psUpload = document.getElementById("ps-upload");
40
+ const psExtract = document.getElementById("ps-extract");
41
+ const psCnn = document.getElementById("ps-cnn");
42
+ const psLstm = document.getElementById("ps-lstm");
43
+ const psVerdict = document.getElementById("ps-verdict");
44
+ const pSteps = [psUpload, psExtract, psCnn, psLstm, psVerdict];
45
+
46
+ // ── State ─────────────────────────────────────────────────────────────────────
47
+ let selectedFile = null;
48
+ let analysisTimer = null;
49
+
50
+ // ── Gauge constants ───────────────────────────────────────────────────────────
51
+ const GAUGE_CIRCUMFERENCE = 330; // stroke-dasharray value in SVG
52
+
53
+ // ── Server Status Check ───────────────────────────────────────────────────────
54
+ async function checkServerStatus() {
55
+ try {
56
+ const res = await fetch("/api/status", { signal: AbortSignal.timeout(4000) });
57
+ if (res.ok) {
58
+ const data = await res.json();
59
+ statusDot.classList.remove("offline");
60
+ statusText.textContent = `Model ready · ${data.device?.toUpperCase() ?? "CPU"}`;
61
+ statusDot.setAttribute("title", "Server online");
62
+ return true;
63
+ }
64
+ } catch (_) {}
65
+ statusDot.classList.add("offline");
66
+ statusText.textContent = "Server offline";
67
+ return false;
68
+ }
69
+
70
+ // ── Toast Notifications ───────────────────────────────────────────────────────
71
+ function toast(message, type = "info", duration = 4500) {
72
+ const icons = { error: "❌", success: "✅", info: "ℹ️" };
73
+ const el = document.createElement("div");
74
+ el.className = `toast ${type}`;
75
+ el.setAttribute("role", "alert");
76
+ el.innerHTML = `<span class="toast-icon" aria-hidden="true">${icons[type]}</span><span>${message}</span>`;
77
+ toastCont.appendChild(el);
78
+ setTimeout(() => {
79
+ el.classList.add("toast-exit");
80
+ el.addEventListener("animationend", () => el.remove());
81
+ }, duration);
82
+ }
83
+
84
+ // ── File Handling ─────────────────────────────────────────────────────────────
85
+ const ALLOWED_TYPES = ["video/mp4", "video/avi", "video/x-msvideo", "video/x-matroska", "video/quicktime", "video/webm", "video/x-ms-wmv"];
86
+ const MAX_SIZE_MB = 500;
87
+
88
+ function isVideoFile(file) {
89
+ if (ALLOWED_TYPES.includes(file.type)) return true;
90
+ const ext = file.name.split(".").pop().toLowerCase();
91
+ return ["mp4","avi","mkv","mov","webm","wmv"].includes(ext);
92
+ }
93
+
94
+ function formatBytes(bytes) {
95
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
96
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
97
+ }
98
+
99
+ function setFile(file) {
100
+ if (!isVideoFile(file)) {
101
+ toast("Unsupported file type. Please upload a video (MP4, AVI, MKV, MOV, WEBM).", "error");
102
+ return;
103
+ }
104
+ if (file.size > MAX_SIZE_MB * 1024 * 1024) {
105
+ toast(`File too large. Maximum allowed size is ${MAX_SIZE_MB} MB.`, "error");
106
+ return;
107
+ }
108
+
109
+ selectedFile = file;
110
+
111
+ // Preview
112
+ const url = URL.createObjectURL(file);
113
+ previewVideo.src = url;
114
+ previewName.textContent = file.name;
115
+ previewMeta.textContent = formatBytes(file.size);
116
+ previewArea.classList.add("visible");
117
+
118
+ btnAnalyze.disabled = false;
119
+
120
+ hideResults();
121
+ toast(`Video selected: ${file.name}`, "success", 3000);
122
+ }
123
+
124
+ function clearFile() {
125
+ selectedFile = null;
126
+ fileInput.value = "";
127
+ previewVideo.src = "";
128
+ previewArea.classList.remove("visible");
129
+ btnAnalyze.disabled = true;
130
+ hideResults();
131
+ }
132
+
133
+ // ── Upload Zone Events ────────────────────────────────────────────────────────
134
+ uploadZone.addEventListener("click", () => fileInput.click());
135
+ uploadZone.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") fileInput.click(); });
136
+
137
+ fileInput.addEventListener("change", () => {
138
+ if (fileInput.files[0]) setFile(fileInput.files[0]);
139
+ });
140
+
141
+ uploadZone.addEventListener("dragenter", e => { e.preventDefault(); uploadZone.classList.add("drag-over"); });
142
+ uploadZone.addEventListener("dragover", e => { e.preventDefault(); uploadZone.classList.add("drag-over"); });
143
+ uploadZone.addEventListener("dragleave", e => {
144
+ if (!uploadZone.contains(e.relatedTarget)) uploadZone.classList.remove("drag-over");
145
+ });
146
+ uploadZone.addEventListener("drop", e => {
147
+ e.preventDefault();
148
+ uploadZone.classList.remove("drag-over");
149
+ const files = e.dataTransfer?.files;
150
+ if (files && files[0]) setFile(files[0]);
151
+ });
152
+
153
+ btnRemove.addEventListener("click", clearFile);
154
+
155
+ // ── Progress Simulation ───────────────────────────────────────────────────────
156
+ function setProgress(pct, label, activeStep) {
157
+ progressBar.style.width = `${pct}%`;
158
+ progressBar.parentElement.setAttribute("aria-valuenow", pct);
159
+ progressLbl.textContent = label;
160
+ progressPct.textContent = `${Math.round(pct)}%`;
161
+
162
+ pSteps.forEach(s => {
163
+ s.classList.remove("active", "done");
164
+ const idx = pSteps.indexOf(s);
165
+ const activeIdx = pSteps.indexOf(activeStep);
166
+ if (idx < activeIdx) s.classList.add("done");
167
+ else if (idx === activeIdx) s.classList.add("active");
168
+ });
169
+ }
170
+
171
+ function startProgressSimulation() {
172
+ clearTimeout(analysisTimer);
173
+ setProgress(5, "Uploading video…", psUpload);
174
+ progressWrap.classList.add("visible");
175
+
176
+ const steps = [
177
+ { delay: 800, pct: 20, label: "Extracting frames from video…", step: psExtract },
178
+ { delay: 2200, pct: 45, label: "Running CNN feature extraction…", step: psCnn },
179
+ { delay: 4000, pct: 70, label: "Processing LSTM temporal sequence…", step: psLstm },
180
+ { delay: 5500, pct: 90, label: "Computing classification verdict…", step: psVerdict },
181
+ ];
182
+
183
+ steps.forEach(({ delay, pct, label, step }) => {
184
+ analysisTimer = setTimeout(() => setProgress(pct, label, step), delay);
185
+ });
186
+ }
187
+
188
+ function finishProgress() {
189
+ clearTimeout(analysisTimer);
190
+ setProgress(100, "Analysis complete!", psVerdict);
191
+ psVerdict.classList.remove("active");
192
+ psVerdict.classList.add("done");
193
+ }
194
+
195
+ // ── Gauge Animation ───────────────────────────────────────────────────────────
196
+ /**
197
+ * The gauge covers ~280° of 360°. stroke-dasharray=330 corresponds to the arc.
198
+ * A confidence of 0% → dashoffset=330 (empty), 100% → dashoffset=0 (full).
199
+ */
200
+ function animateGauge(confidencePct, isAI) {
201
+ const fillClass = isAI ? "ai-fill" : "real-fill";
202
+ gaugeFill.setAttribute("class", `gauge-fill ${fillClass}`);
203
+
204
+ const offset = GAUGE_CIRCUMFERENCE - (confidencePct / 100) * GAUGE_CIRCUMFERENCE;
205
+ // Start at empty
206
+ gaugeFill.style.strokeDashoffset = GAUGE_CIRCUMFERENCE;
207
+ gaugePctTxt.textContent = "--";
208
+
209
+ requestAnimationFrame(() => {
210
+ requestAnimationFrame(() => {
211
+ gaugeFill.style.strokeDashoffset = offset;
212
+ });
213
+ });
214
+
215
+ // Animate number counter
216
+ let start = 0;
217
+ const end = confidencePct;
218
+ const duration = 1200;
219
+ const startTime = performance.now();
220
+
221
+ function step(now) {
222
+ const elapsed = now - startTime;
223
+ const progress = Math.min(elapsed / duration, 1);
224
+ const ease = 1 - Math.pow(1 - progress, 3);
225
+ start = Math.round(ease * end);
226
+ gaugePctTxt.textContent = `${start}%`;
227
+ if (progress < 1) requestAnimationFrame(step);
228
+ }
229
+ requestAnimationFrame(step);
230
+ }
231
+
232
+ // ── Results Rendering ─────────────────────────────────────────────────────────
233
+ function showResults(data) {
234
+ const { verdict, is_ai, probability, confidence, processing_time, threshold } = data;
235
+
236
+ // Card theme
237
+ resultCard.classList.remove("ai-result", "real-result");
238
+ resultCard.classList.add(is_ai ? "ai-result" : "real-result");
239
+
240
+ // Verdict badge
241
+ verdictBadge.classList.remove("ai-badge", "real-badge");
242
+ verdictBadge.classList.add(is_ai ? "ai-badge" : "real-badge");
243
+ verdictIcon.textContent = is_ai ? "🤖" : "✅";
244
+ verdictText.textContent = verdict;
245
+ verdictBadge.setAttribute("aria-label", `Verdict: ${verdict}`);
246
+
247
+ // Gauge
248
+ animateGauge(Math.round(confidence), is_ai);
249
+
250
+ // Metrics
251
+ mProb.textContent = probability.toFixed(4);
252
+ mProb.className = "metric-value";
253
+ mProb.classList.add(is_ai ? "red" : "green");
254
+ mConf.textContent = `${confidence.toFixed(1)}%`;
255
+ mConf.className = "metric-value";
256
+ mConf.classList.add(is_ai ? "red" : "green");
257
+ mTime.textContent = `${processing_time}s`;
258
+ mThresh.textContent = is_ai ? "No ✗" : "Yes ✓";
259
+ mThresh.className = `metric-value ${is_ai ? "red" : "green"}`;
260
+
261
+ // Description
262
+ resultDesc.className = "result-desc";
263
+ resultDesc.classList.add(is_ai ? "ai" : "real");
264
+ resultDesc.textContent = is_ai
265
+ ? `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)}%.`
266
+ : `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)}%.`;
267
+
268
+ resultsPanel.classList.add("visible");
269
+ }
270
+
271
+ function hideResults() {
272
+ resultsPanel.classList.remove("visible");
273
+ progressWrap.classList.remove("visible");
274
+ pSteps.forEach(s => s.classList.remove("active", "done"));
275
+ }
276
+
277
+ // ── Analyze ───────────────────────────────────────────────────────────────────
278
+ btnAnalyze.addEventListener("click", async () => {
279
+ if (!selectedFile) return;
280
+
281
+ const online = await checkServerStatus();
282
+ if (!online) {
283
+ toast("Cannot connect to the server. Make sure app.py is running.", "error", 6000);
284
+ return;
285
+ }
286
+
287
+ // UI: loading state
288
+ btnAnalyze.disabled = true;
289
+ btnAnalyze.classList.add("loading");
290
+ btnAnalyze.setAttribute("aria-busy", "true");
291
+ hideResults();
292
+ startProgressSimulation();
293
+
294
+ const formData = new FormData();
295
+ formData.append("video", selectedFile, selectedFile.name);
296
+
297
+ try {
298
+ const res = await fetch("/api/predict", {
299
+ method: "POST",
300
+ body: formData,
301
+ });
302
+
303
+ finishProgress();
304
+
305
+ if (!res.ok) {
306
+ let errMsg = `Server error ${res.status}`;
307
+ try {
308
+ const errData = await res.json();
309
+ errMsg = errData.error || errMsg;
310
+ } catch (_) {}
311
+ toast(`Analysis failed: ${errMsg}`, "error", 7000);
312
+ return;
313
+ }
314
+
315
+ const data = await res.json();
316
+
317
+ // Brief pause so progress animation finishes
318
+ await new Promise(r => setTimeout(r, 500));
319
+ showResults(data);
320
+ toast(
321
+ `Analysis complete: ${data.verdict} (${data.confidence.toFixed(1)}% confidence)`,
322
+ data.is_ai ? "info" : "success",
323
+ 5000
324
+ );
325
+
326
+ } catch (err) {
327
+ toast(`Network error: ${err.message}`, "error", 7000);
328
+ console.error(err);
329
+ } finally {
330
+ btnAnalyze.disabled = false;
331
+ btnAnalyze.classList.remove("loading");
332
+ btnAnalyze.setAttribute("aria-busy", "false");
333
+ }
334
+ });
335
+
336
+ // ── Reset ─────────────────────────────────────────────────────────────────────
337
+ btnReset.addEventListener("click", () => {
338
+ clearFile();
339
+ hideResults();
340
+ window.scrollTo({ top: 0, behavior: "smooth" });
341
+ toast("Ready for a new analysis.", "info", 2500);
342
+ });
343
+
344
+ // ── Init ──────────────────────────────────────────────────────────────────────
345
+ (async function init() {
346
+ await checkServerStatus();
347
+ // Recheck every 30 seconds
348
+ setInterval(checkServerStatus, 30_000);
349
+ })();
utils/__init__.py ADDED
File without changes
utils/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (131 Bytes). View file
 
utils/__pycache__/video_utils.cpython-310.pyc ADDED
Binary file (2.77 kB). View file
 
utils/video_utils.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import cv2
3
+ import numpy as np
4
+ import torch
5
+ import os
6
+ from typing import List, Tuple, Optional
7
+
8
+ def get_video_properties(video_path: str) -> dict:
9
+ """
10
+ Get video properties.
11
+ """
12
+ cap = cv2.VideoCapture(video_path)
13
+ if not cap.isOpened():
14
+ return {}
15
+
16
+ props = {
17
+ 'width': int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
18
+ 'height': int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
19
+ 'fps': cap.get(cv2.CAP_PROP_FPS),
20
+ 'frame_count': int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
21
+ }
22
+ cap.release()
23
+ return props
24
+
25
+ def load_video_frames(
26
+ video_path: str,
27
+ num_frames: int = 30,
28
+ frame_size: Tuple[int, int] = (224, 224),
29
+ frame_skip: int = 1
30
+ ) -> Optional[np.ndarray]:
31
+ """
32
+ Load frames from a video with robust preprocessing:
33
+ 1. Center crop to square (min dimension).
34
+ 2. Resize to frame_size.
35
+ 3. Sample frames uniformly.
36
+ """
37
+ cap = cv2.VideoCapture(video_path)
38
+ if not cap.isOpened():
39
+ print(f"Error opening video: {video_path}")
40
+ return None
41
+
42
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
43
+ if total_frames <= 0:
44
+ cap.release()
45
+ return None
46
+
47
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
48
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
49
+
50
+ # Calculate crop coordinates for center square crop
51
+ min_dim = min(width, height)
52
+ start_x = (width - min_dim) // 2
53
+ start_y = (height - min_dim) // 2
54
+
55
+ # Calculate frame indices to sample
56
+ # We want 'num_frames' frames.
57
+ # Strategy: evenly space them across the video duration we look at.
58
+ # But for simplicity and consistency, let's just grab them with a stride,
59
+ # or if video is short, grab all and pad.
60
+
61
+ # Let's try to span as much of the video as possible?
62
+ # Or just stick to the requested architecture of sampling segments.
63
+ # The prompt asked for "preprocess ... not based on aspect ratio".
64
+
65
+ # Simple strategy: Read frames with skip, up to num_frames.
66
+ # If video is too short, loop/pad?
67
+ # Better: Reservoir sampling or Linspace if we want fixed count?
68
+ # Let's stick to the user's likely need: Fixed number of frames.
69
+
70
+ sampled_frames = []
71
+
72
+ # We'll seek effectively.
73
+ # But looping is safer for some codecs.
74
+
75
+ # Improved sampling: pick indices using linspace if we want to span whole video?
76
+ # Or just sequential for temporal consistency (RNN prefer sequences).
77
+ # Let's do sequential with skip.
78
+
79
+ frame_idx = 0
80
+ frames_collected = 0
81
+
82
+ while frames_collected < num_frames:
83
+ ret, frame = cap.read()
84
+ if not ret:
85
+ break
86
+
87
+ if frame_idx % frame_skip == 0:
88
+ # Preprocess Frame
89
+
90
+ # 1. Center Crop
91
+ crop = frame[start_y:start_y+min_dim, start_x:start_x+min_dim]
92
+
93
+ # 2. Resize
94
+ resized = cv2.resize(crop, frame_size)
95
+
96
+ # 3. Convert BGR to RGB
97
+ rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
98
+
99
+ sampled_frames.append(rgb)
100
+ frames_collected += 1
101
+
102
+ frame_idx += 1
103
+
104
+ cap.release()
105
+
106
+ # Handle insufficient frames
107
+ if len(sampled_frames) == 0:
108
+ return None
109
+
110
+ if len(sampled_frames) < num_frames:
111
+ # Pad with last frame or zeros?
112
+ # Let's pad with zeros (black frames) or loop?
113
+ # Zero padding is safer to avoid motion artifacts.
114
+ padding = [np.zeros((frame_size[1], frame_size[0], 3), dtype=np.uint8)] * (num_frames - len(sampled_frames))
115
+ sampled_frames.extend(padding)
116
+
117
+ return np.array(sampled_frames)
118
+
119
+ def normalize_frames(frames: np.ndarray) -> np.ndarray:
120
+ """
121
+ Normalize frames to [0, 1] and then standard ImageNet mean/std.
122
+ Frames input: (N, H, W, C) in RGB, uint8 [0,255]
123
+ """
124
+ # Convert to float32 [0, 1]
125
+ frames_norm = frames.astype(np.float32) / 255.0
126
+
127
+ # Standard ImageNet mean and std
128
+ mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
129
+ std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
130
+
131
+ # Apply normalization
132
+ # frames is (N, H, W, C), mean/std are (3,)
133
+ # We allow broadcasting on the last dimension
134
+ frames_norm = (frames_norm - mean) / std
135
+
136
+ return frames_norm
137
+
138
+ def validate_video(video_path: str) -> bool:
139
+ """
140
+ Check if video is valid and openable.
141
+ """
142
+ if not os.path.exists(video_path):
143
+ return False
144
+ try:
145
+ cap = cv2.VideoCapture(video_path)
146
+ if not cap.isOpened():
147
+ return False
148
+ # Read one frame to be sure
149
+ ret, _ = cap.read()
150
+ cap.release()
151
+ return ret
152
+ except Exception:
153
+ return False