Deepfake Authenticator commited on
Commit Β·
03f204c
1
Parent(s): faea1f9
fix: audio-visual mismatch detection for face-swap deepfakes
Browse files- Swap audio model to Vansh180/deepfake-audio-wav2vec2 (ASVspoof-trained, 92.8% acc)
- Add AV_MISMATCH detection: when visual says FAKE but voice is human
this is the hallmark of face-swap deepfakes (dubbed movie audio)
- AV_MISMATCH boosts audio fake score based on visual confidence
- New result label: AV_MISMATCH (yellow warning) vs AI_VOICE (red) vs HUMAN_VOICE (green)
- Frontend: yellow warning badge + icon for AV_MISMATCH state
- Fix tooltip text visibility (white on dark, colored border)
- Fix timeline bar heights (px not %, overflow:visible for tooltips)
- backend/audio_detector.py +91 -31
- backend/detector.py +3 -2
- frontend-vanilla/index.html +50 -40
backend/audio_detector.py
CHANGED
|
@@ -221,44 +221,47 @@ class AudioAnalysisAgent:
|
|
| 221 |
# Wav2Vec2 model for AI voice detection
|
| 222 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
class AudioDecisionAgent:
|
| 224 |
-
|
| 225 |
-
|
|
|
|
| 226 |
TARGET_SR = 16000
|
| 227 |
|
| 228 |
def __init__(self):
|
| 229 |
self.model = None
|
| 230 |
self.processor = None
|
| 231 |
-
self.fake_idx =
|
| 232 |
self.available = False
|
| 233 |
self._load()
|
| 234 |
|
| 235 |
def _load(self):
|
| 236 |
try:
|
| 237 |
from transformers import (
|
| 238 |
-
|
| 239 |
-
|
| 240 |
)
|
| 241 |
logger.info(f"Loading audio model: {self.MODEL_ID}")
|
| 242 |
-
self.processor =
|
| 243 |
-
self.model =
|
| 244 |
self.model.eval()
|
| 245 |
|
| 246 |
-
#
|
| 247 |
for idx, lbl in self.model.config.id2label.items():
|
| 248 |
-
|
|
|
|
| 249 |
self.fake_idx = idx
|
| 250 |
break
|
| 251 |
|
| 252 |
self.available = True
|
| 253 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 254 |
except Exception as e:
|
| 255 |
logger.warning(f"Audio model unavailable: {e}")
|
| 256 |
self.available = False
|
| 257 |
|
| 258 |
def predict(self, waveform: np.ndarray, sr: int) -> float:
|
| 259 |
-
"""
|
| 260 |
-
Run Wav2Vec2 on audio in chunks, return mean fake probability.
|
| 261 |
-
"""
|
| 262 |
if not self.available:
|
| 263 |
return 0.5
|
| 264 |
|
|
@@ -268,7 +271,7 @@ class AudioDecisionAgent:
|
|
| 268 |
chunks = [
|
| 269 |
waveform[i : i + chunk_size]
|
| 270 |
for i in range(0, len(waveform), chunk_size)
|
| 271 |
-
if len(waveform[i : i + chunk_size]) > sr // 2
|
| 272 |
]
|
| 273 |
|
| 274 |
if not chunks:
|
|
@@ -310,6 +313,7 @@ class AudioReportAgent:
|
|
| 310 |
model_prob: float,
|
| 311 |
heuristic: dict,
|
| 312 |
has_audio: bool,
|
|
|
|
| 313 |
) -> dict:
|
| 314 |
if not has_audio:
|
| 315 |
return {
|
|
@@ -329,21 +333,46 @@ class AudioReportAgent:
|
|
| 329 |
else:
|
| 330 |
combined = model_prob
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
combined = float(np.clip(combined, 0.0, 1.0))
|
| 333 |
is_fake = combined >= self.FAKE_THRESHOLD
|
| 334 |
confidence = round(combined * 100, 1)
|
| 335 |
|
| 336 |
-
details = self._build_details(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
return {
|
| 339 |
-
"available":
|
| 340 |
-
"result":
|
| 341 |
-
"confidence":
|
| 342 |
"fake_probability": round(combined, 4),
|
| 343 |
-
"model_score":
|
| 344 |
-
"heuristic_score":
|
| 345 |
-
"
|
| 346 |
-
"
|
|
|
|
| 347 |
}
|
| 348 |
|
| 349 |
def _build_details(
|
|
@@ -353,9 +382,26 @@ class AudioReportAgent:
|
|
| 353 |
features: dict,
|
| 354 |
model_prob: float,
|
| 355 |
heur_prob: float,
|
|
|
|
| 356 |
) -> list[str]:
|
| 357 |
details = []
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
if is_fake:
|
| 360 |
if prob > 0.85:
|
| 361 |
details.append("High-confidence AI-generated voice detected")
|
|
@@ -366,17 +412,26 @@ class AudioReportAgent:
|
|
| 366 |
|
| 367 |
pitch_std = features.get("pitch_std_hz")
|
| 368 |
if pitch_std is not None and pitch_std < 15:
|
| 369 |
-
details.append(
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
delta_var = features.get("mfcc_delta_var")
|
| 372 |
if delta_var is not None and delta_var < 1.5:
|
| 373 |
-
details.append(
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
silence = features.get("silence_ratio")
|
| 376 |
if silence is not None and silence < 0.05:
|
| 377 |
-
details.append(
|
|
|
|
|
|
|
|
|
|
| 378 |
|
| 379 |
-
details.append(f"
|
| 380 |
else:
|
| 381 |
if prob < 0.25:
|
| 382 |
details.append("Strong indicators of authentic human voice")
|
|
@@ -389,9 +444,11 @@ class AudioReportAgent:
|
|
| 389 |
|
| 390 |
silence = features.get("silence_ratio")
|
| 391 |
if silence is not None and 0.05 <= silence <= 0.35:
|
| 392 |
-
details.append(
|
|
|
|
|
|
|
| 393 |
|
| 394 |
-
details.append(f"
|
| 395 |
|
| 396 |
return details
|
| 397 |
|
|
@@ -406,7 +463,7 @@ class AudioAuthenticator:
|
|
| 406 |
self.decision = AudioDecisionAgent()
|
| 407 |
self.reporter = AudioReportAgent()
|
| 408 |
|
| 409 |
-
def analyze(self, video_path: str) -> dict:
|
| 410 |
# Step 1: Extract audio
|
| 411 |
waveform, sr = self.extractor.extract(video_path)
|
| 412 |
|
|
@@ -419,5 +476,8 @@ class AudioAuthenticator:
|
|
| 419 |
# Step 3: Model prediction
|
| 420 |
model_prob = self.decision.predict(waveform, sr)
|
| 421 |
|
| 422 |
-
# Step 4: Report
|
| 423 |
-
return self.reporter.generate(
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
# Wav2Vec2 model for AI voice detection
|
| 222 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 223 |
class AudioDecisionAgent:
|
| 224 |
+
# Primary: ASVspoof-trained model with bonafide/spoof labels
|
| 225 |
+
MODEL_ID = "Vansh180/deepfake-audio-wav2vec2"
|
| 226 |
+
CHUNK_SEC = 10
|
| 227 |
TARGET_SR = 16000
|
| 228 |
|
| 229 |
def __init__(self):
|
| 230 |
self.model = None
|
| 231 |
self.processor = None
|
| 232 |
+
self.fake_idx = 1 # default: label 1 = spoof/fake
|
| 233 |
self.available = False
|
| 234 |
self._load()
|
| 235 |
|
| 236 |
def _load(self):
|
| 237 |
try:
|
| 238 |
from transformers import (
|
| 239 |
+
AutoModelForAudioClassification,
|
| 240 |
+
AutoFeatureExtractor,
|
| 241 |
)
|
| 242 |
logger.info(f"Loading audio model: {self.MODEL_ID}")
|
| 243 |
+
self.processor = AutoFeatureExtractor.from_pretrained(self.MODEL_ID)
|
| 244 |
+
self.model = AutoModelForAudioClassification.from_pretrained(self.MODEL_ID)
|
| 245 |
self.model.eval()
|
| 246 |
|
| 247 |
+
# Find fake/spoof label index
|
| 248 |
for idx, lbl in self.model.config.id2label.items():
|
| 249 |
+
lbl_lower = lbl.lower()
|
| 250 |
+
if any(w in lbl_lower for w in ("fake", "spoof", "synthetic", "generated")):
|
| 251 |
self.fake_idx = idx
|
| 252 |
break
|
| 253 |
|
| 254 |
self.available = True
|
| 255 |
+
logger.info(
|
| 256 |
+
f"Audio model loaded β labels={self.model.config.id2label} "
|
| 257 |
+
f"fake_idx={self.fake_idx}"
|
| 258 |
+
)
|
| 259 |
except Exception as e:
|
| 260 |
logger.warning(f"Audio model unavailable: {e}")
|
| 261 |
self.available = False
|
| 262 |
|
| 263 |
def predict(self, waveform: np.ndarray, sr: int) -> float:
|
| 264 |
+
"""Run model on audio chunks, return mean fake probability."""
|
|
|
|
|
|
|
| 265 |
if not self.available:
|
| 266 |
return 0.5
|
| 267 |
|
|
|
|
| 271 |
chunks = [
|
| 272 |
waveform[i : i + chunk_size]
|
| 273 |
for i in range(0, len(waveform), chunk_size)
|
| 274 |
+
if len(waveform[i : i + chunk_size]) > sr // 2
|
| 275 |
]
|
| 276 |
|
| 277 |
if not chunks:
|
|
|
|
| 313 |
model_prob: float,
|
| 314 |
heuristic: dict,
|
| 315 |
has_audio: bool,
|
| 316 |
+
visual_fake_prob: float = 0.5,
|
| 317 |
) -> dict:
|
| 318 |
if not has_audio:
|
| 319 |
return {
|
|
|
|
| 333 |
else:
|
| 334 |
combined = model_prob
|
| 335 |
|
| 336 |
+
# ββ Audio-Visual Mismatch Boost βββββββββββββββββββββββββββββββ
|
| 337 |
+
# Key insight: in face-swap deepfakes, the FACE is fake but the
|
| 338 |
+
# VOICE is real (dubbed from original footage). This mismatch
|
| 339 |
+
# is itself a strong deepfake signal.
|
| 340 |
+
# If visual says FAKE (high prob) but audio says HUMAN β mismatch
|
| 341 |
+
av_mismatch = False
|
| 342 |
+
av_mismatch_score = 0.0
|
| 343 |
+
if visual_fake_prob >= 0.55 and model_prob < 0.50:
|
| 344 |
+
# Visual strongly fake, audio sounds human β classic face-swap
|
| 345 |
+
av_mismatch = True
|
| 346 |
+
av_mismatch_score = visual_fake_prob * 0.6
|
| 347 |
+
# Boost audio fake probability to reflect the mismatch
|
| 348 |
+
combined = max(combined, av_mismatch_score)
|
| 349 |
+
logger.info(
|
| 350 |
+
f"Audio-visual mismatch detected: visual_fake={visual_fake_prob:.2f} "
|
| 351 |
+
f"audio_fake={model_prob:.2f} β boosted to {combined:.2f}"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
combined = float(np.clip(combined, 0.0, 1.0))
|
| 355 |
is_fake = combined >= self.FAKE_THRESHOLD
|
| 356 |
confidence = round(combined * 100, 1)
|
| 357 |
|
| 358 |
+
details = self._build_details(
|
| 359 |
+
combined, is_fake, features, model_prob, heur_prob, av_mismatch
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
result_label = "AI_VOICE" if is_fake else "HUMAN_VOICE"
|
| 363 |
+
if av_mismatch:
|
| 364 |
+
result_label = "AV_MISMATCH" # special label for face-swap case
|
| 365 |
|
| 366 |
return {
|
| 367 |
+
"available": True,
|
| 368 |
+
"result": result_label,
|
| 369 |
+
"confidence": confidence,
|
| 370 |
"fake_probability": round(combined, 4),
|
| 371 |
+
"model_score": round(model_prob * 100, 1),
|
| 372 |
+
"heuristic_score": round(heur_prob * 100, 1),
|
| 373 |
+
"av_mismatch": av_mismatch,
|
| 374 |
+
"details": details,
|
| 375 |
+
"features": features,
|
| 376 |
}
|
| 377 |
|
| 378 |
def _build_details(
|
|
|
|
| 382 |
features: dict,
|
| 383 |
model_prob: float,
|
| 384 |
heur_prob: float,
|
| 385 |
+
av_mismatch: bool = False,
|
| 386 |
) -> list[str]:
|
| 387 |
details = []
|
| 388 |
|
| 389 |
+
# Audio-visual mismatch is the most important signal
|
| 390 |
+
if av_mismatch:
|
| 391 |
+
details.append(
|
| 392 |
+
"β οΈ Audio-visual mismatch detected β face appears manipulated but voice is human. "
|
| 393 |
+
"This is the hallmark of face-swap deepfakes where original audio is preserved."
|
| 394 |
+
)
|
| 395 |
+
details.append(
|
| 396 |
+
"Voice is authentic human speech, but does NOT match the manipulated face β "
|
| 397 |
+
"consistent with dubbed deepfake video (e.g. movie scene re-faced)"
|
| 398 |
+
)
|
| 399 |
+
details.append(
|
| 400 |
+
f"Visual deepfake confidence was high while voice model scored {(1-model_prob)*100:.1f}% human β "
|
| 401 |
+
"strong indicator of face-swap rather than full synthesis"
|
| 402 |
+
)
|
| 403 |
+
return details
|
| 404 |
+
|
| 405 |
if is_fake:
|
| 406 |
if prob > 0.85:
|
| 407 |
details.append("High-confidence AI-generated voice detected")
|
|
|
|
| 412 |
|
| 413 |
pitch_std = features.get("pitch_std_hz")
|
| 414 |
if pitch_std is not None and pitch_std < 15:
|
| 415 |
+
details.append(
|
| 416 |
+
f"Unnaturally stable pitch (Ο={pitch_std}Hz) β "
|
| 417 |
+
"human speech typically varies 20-80Hz"
|
| 418 |
+
)
|
| 419 |
|
| 420 |
delta_var = features.get("mfcc_delta_var")
|
| 421 |
if delta_var is not None and delta_var < 1.5:
|
| 422 |
+
details.append(
|
| 423 |
+
"Insufficient micro-variation in articulation β "
|
| 424 |
+
"characteristic of TTS synthesis"
|
| 425 |
+
)
|
| 426 |
|
| 427 |
silence = features.get("silence_ratio")
|
| 428 |
if silence is not None and silence < 0.05:
|
| 429 |
+
details.append(
|
| 430 |
+
"No natural breath pauses detected β "
|
| 431 |
+
"AI voices lack organic speech rhythm"
|
| 432 |
+
)
|
| 433 |
|
| 434 |
+
details.append(f"ASVspoof model confidence: {model_prob*100:.1f}% synthetic")
|
| 435 |
else:
|
| 436 |
if prob < 0.25:
|
| 437 |
details.append("Strong indicators of authentic human voice")
|
|
|
|
| 444 |
|
| 445 |
silence = features.get("silence_ratio")
|
| 446 |
if silence is not None and 0.05 <= silence <= 0.35:
|
| 447 |
+
details.append(
|
| 448 |
+
"Natural speech rhythm with organic pauses and breath sounds"
|
| 449 |
+
)
|
| 450 |
|
| 451 |
+
details.append(f"ASVspoof model confidence: {(1-model_prob)*100:.1f}% human")
|
| 452 |
|
| 453 |
return details
|
| 454 |
|
|
|
|
| 463 |
self.decision = AudioDecisionAgent()
|
| 464 |
self.reporter = AudioReportAgent()
|
| 465 |
|
| 466 |
+
def analyze(self, video_path: str, visual_fake_prob: float = 0.5) -> dict:
|
| 467 |
# Step 1: Extract audio
|
| 468 |
waveform, sr = self.extractor.extract(video_path)
|
| 469 |
|
|
|
|
| 476 |
# Step 3: Model prediction
|
| 477 |
model_prob = self.decision.predict(waveform, sr)
|
| 478 |
|
| 479 |
+
# Step 4: Report (pass visual prob for mismatch detection)
|
| 480 |
+
return self.reporter.generate(
|
| 481 |
+
model_prob, heuristic, has_audio=True,
|
| 482 |
+
visual_fake_prob=visual_fake_prob,
|
| 483 |
+
)
|
backend/detector.py
CHANGED
|
@@ -623,12 +623,13 @@ class DeepfakeAuthenticator:
|
|
| 623 |
# Step 3: Visual decision
|
| 624 |
analysis = self.decision_agent.analyze_frames(frames, face_crops_per_frame)
|
| 625 |
|
| 626 |
-
# Step 4: Audio analysis
|
| 627 |
audio_result = {"available": False, "result": "NO_AUDIO", "confidence": 0, "details": []}
|
| 628 |
audio_agent = self._get_audio()
|
| 629 |
if audio_agent:
|
| 630 |
try:
|
| 631 |
-
|
|
|
|
| 632 |
except Exception as e:
|
| 633 |
logger.warning(f"Audio analysis failed: {e}")
|
| 634 |
|
|
|
|
| 623 |
# Step 3: Visual decision
|
| 624 |
analysis = self.decision_agent.analyze_frames(frames, face_crops_per_frame)
|
| 625 |
|
| 626 |
+
# Step 4: Audio analysis β pass visual prob for mismatch detection
|
| 627 |
audio_result = {"available": False, "result": "NO_AUDIO", "confidence": 0, "details": []}
|
| 628 |
audio_agent = self._get_audio()
|
| 629 |
if audio_agent:
|
| 630 |
try:
|
| 631 |
+
visual_prob = analysis.get("overall_fake_probability", 0.5)
|
| 632 |
+
audio_result = audio_agent.analyze(video_path, visual_fake_prob=visual_prob)
|
| 633 |
except Exception as e:
|
| 634 |
logger.warning(f"Audio analysis failed: {e}")
|
| 635 |
|
frontend-vanilla/index.html
CHANGED
|
@@ -223,21 +223,24 @@
|
|
| 223 |
}
|
| 224 |
.tl-tooltip {
|
| 225 |
position: absolute;
|
| 226 |
-
bottom: calc(100% +
|
| 227 |
left: 50%;
|
| 228 |
transform: translateX(-50%);
|
| 229 |
-
background:
|
| 230 |
-
border: 1px solid
|
| 231 |
border-radius: 4px;
|
| 232 |
-
padding:
|
| 233 |
-
font-size:
|
|
|
|
| 234 |
white-space: nowrap;
|
| 235 |
pointer-events: none;
|
| 236 |
opacity: 0;
|
| 237 |
-
transition:
|
| 238 |
-
z-index:
|
| 239 |
-
font-family: 'Space Grotesk',
|
| 240 |
-
color: #
|
|
|
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
/* ββ Scrollbar ββ */
|
|
@@ -668,7 +671,7 @@
|
|
| 668 |
<span class="font-data-mono text-[9px] text-outline">0%</span>
|
| 669 |
</div>
|
| 670 |
<!-- Bars container -->
|
| 671 |
-
<div id="timelineBars" class="
|
| 672 |
<!-- Populated by JS -->
|
| 673 |
</div>
|
| 674 |
</div>
|
|
@@ -1147,19 +1150,19 @@ function renderInsightCards(details, isFake, conf) {
|
|
| 1147 |
function renderMetaTerminal(data) {
|
| 1148 |
const meta = data.metadata || {};
|
| 1149 |
const rows = [
|
| 1150 |
-
['RESULT',
|
| 1151 |
-
['FRAMES_ANALYZED',
|
| 1152 |
-
['FACES_DETECTED',
|
| 1153 |
-
['DURATION',
|
| 1154 |
-
['FPS',
|
| 1155 |
-
['RESOLUTION',
|
| 1156 |
-
['PROC_TIME',
|
| 1157 |
];
|
| 1158 |
|
| 1159 |
const html = rows.map(([k, v]) => `
|
| 1160 |
-
<div class="flex">
|
| 1161 |
-
<span class="text-outline
|
| 1162 |
-
<span class="text-on-surface">${escHtml(String(v))}</span>
|
| 1163 |
</div>`).join('') + `
|
| 1164 |
<div class="my-3 border-t border-surface-variant/30 border-dashed"></div>
|
| 1165 |
<div class="text-primary-container opacity-80">> INIT NEURAL_NET_V4.2</div>
|
|
@@ -1189,17 +1192,19 @@ function renderTimeline(frames) {
|
|
| 1189 |
const maxH = 140; // px
|
| 1190 |
|
| 1191 |
container.innerHTML = subset.map(f => {
|
| 1192 |
-
const pct
|
| 1193 |
-
const
|
| 1194 |
-
const isHigh
|
| 1195 |
-
const barBg
|
| 1196 |
-
const barGlow
|
| 1197 |
-
? 'box-shadow:0 0 8px rgba(255,180,171,0.
|
| 1198 |
-
: 'box-shadow:0 0 6px rgba(0,255,156,0.
|
|
|
|
|
|
|
| 1199 |
return `
|
| 1200 |
-
<div class="tl-bar" style="height:${maxH}px;position:relative;">
|
| 1201 |
-
<div class="tl-tooltip"
|
| 1202 |
-
<div style="position:absolute;bottom:0;left:
|
| 1203 |
</div>`;
|
| 1204 |
}).join('');
|
| 1205 |
|
|
@@ -1212,18 +1217,22 @@ function renderTimeline(frames) {
|
|
| 1212 |
|
| 1213 |
// ββ Audio section βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1214 |
function renderAudio(audio) {
|
| 1215 |
-
const isAI
|
|
|
|
|
|
|
| 1216 |
|
| 1217 |
const icon = document.getElementById('audioIcon');
|
| 1218 |
-
icon.textContent = isAI ? 'smart_toy' : 'record_voice_over';
|
| 1219 |
-
icon.style.color =
|
| 1220 |
|
| 1221 |
const verdict = document.getElementById('audioVerdict');
|
| 1222 |
-
verdict.textContent = audio.result || '--';
|
| 1223 |
-
verdict.style.color =
|
| 1224 |
|
| 1225 |
document.getElementById('audioConfidence').textContent =
|
| 1226 |
-
|
|
|
|
|
|
|
| 1227 |
|
| 1228 |
const modelScore = typeof audio.model_score === 'number' ? audio.model_score : 0;
|
| 1229 |
const heurScore = typeof audio.heuristic_score === 'number' ? audio.heuristic_score : 0;
|
|
@@ -1233,13 +1242,14 @@ function renderAudio(audio) {
|
|
| 1233 |
document.getElementById('audioHeuristicBar').style.width = heurScore + '%';
|
| 1234 |
document.getElementById('audioHeuristicScore').textContent = heurScore.toFixed(1) + '%';
|
| 1235 |
|
| 1236 |
-
// Audio details
|
| 1237 |
const detailsEl = document.getElementById('audioDetails');
|
| 1238 |
if (audio.details && audio.details.length) {
|
| 1239 |
detailsEl.innerHTML = audio.details.map(d => `
|
| 1240 |
-
<div class="flex items-start gap-3 p-3 bg-surface-container-low/50 border border-outline-variant/30 rounded-DEFAULT"
|
| 1241 |
-
|
| 1242 |
-
<span class="
|
|
|
|
| 1243 |
</div>`).join('');
|
| 1244 |
} else {
|
| 1245 |
detailsEl.innerHTML = '';
|
|
|
|
| 223 |
}
|
| 224 |
.tl-tooltip {
|
| 225 |
position: absolute;
|
| 226 |
+
bottom: calc(100% + 8px);
|
| 227 |
left: 50%;
|
| 228 |
transform: translateX(-50%);
|
| 229 |
+
background: #0a0f0a;
|
| 230 |
+
border: 1px solid #00ff9c;
|
| 231 |
border-radius: 4px;
|
| 232 |
+
padding: 4px 8px;
|
| 233 |
+
font-size: 11px;
|
| 234 |
+
font-weight: 700;
|
| 235 |
white-space: nowrap;
|
| 236 |
pointer-events: none;
|
| 237 |
opacity: 0;
|
| 238 |
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
| 239 |
+
z-index: 100;
|
| 240 |
+
font-family: 'Space Grotesk', monospace;
|
| 241 |
+
color: #ffffff !important;
|
| 242 |
+
letter-spacing: 0.05em;
|
| 243 |
+
box-shadow: 0 0 10px rgba(0,255,156,0.3);
|
| 244 |
}
|
| 245 |
|
| 246 |
/* ββ Scrollbar ββ */
|
|
|
|
| 671 |
<span class="font-data-mono text-[9px] text-outline">0%</span>
|
| 672 |
</div>
|
| 673 |
<!-- Bars container -->
|
| 674 |
+
<div id="timelineBars" class="flex items-end gap-[2px] border-b border-l border-surface-variant pl-8 relative z-10" style="height:160px;overflow:visible;">
|
| 675 |
<!-- Populated by JS -->
|
| 676 |
</div>
|
| 677 |
</div>
|
|
|
|
| 1150 |
function renderMetaTerminal(data) {
|
| 1151 |
const meta = data.metadata || {};
|
| 1152 |
const rows = [
|
| 1153 |
+
['RESULT', data.result || '--'],
|
| 1154 |
+
['FRAMES_ANALYZED', meta.frames_analyzed ?? '--'],
|
| 1155 |
+
['FACES_DETECTED', meta.frames_with_faces ?? '--'],
|
| 1156 |
+
['DURATION', meta.video_duration_sec != null ? meta.video_duration_sec.toFixed(1) + 's' : '--'],
|
| 1157 |
+
['FPS', meta.video_fps ?? '--'],
|
| 1158 |
+
['RESOLUTION', meta.resolution || '--'],
|
| 1159 |
+
['PROC_TIME', data.processing_time_sec != null ? data.processing_time_sec.toFixed(2) + 's' : '--'],
|
| 1160 |
];
|
| 1161 |
|
| 1162 |
const html = rows.map(([k, v]) => `
|
| 1163 |
+
<div class="flex items-baseline gap-0">
|
| 1164 |
+
<span class="text-outline shrink-0" style="width:11rem;min-width:11rem;">${k}:</span>
|
| 1165 |
+
<span class="text-on-surface font-semibold">${escHtml(String(v))}</span>
|
| 1166 |
</div>`).join('') + `
|
| 1167 |
<div class="my-3 border-t border-surface-variant/30 border-dashed"></div>
|
| 1168 |
<div class="text-primary-container opacity-80">> INIT NEURAL_NET_V4.2</div>
|
|
|
|
| 1192 |
const maxH = 140; // px
|
| 1193 |
|
| 1194 |
container.innerHTML = subset.map(f => {
|
| 1195 |
+
const pct = typeof f.fake_pct === 'number' ? f.fake_pct : 0;
|
| 1196 |
+
const barH = Math.max(3, Math.round((pct / 100) * maxH)); // px, not %
|
| 1197 |
+
const isHigh = pct >= 60;
|
| 1198 |
+
const barBg = isHigh ? '#ffb4ab' : '#00ff9c';
|
| 1199 |
+
const barGlow = isHigh
|
| 1200 |
+
? 'box-shadow:0 0 8px rgba(255,180,171,0.6);'
|
| 1201 |
+
: 'box-shadow:0 0 6px rgba(0,255,156,0.4);';
|
| 1202 |
+
const tipLabel = isHigh ? `β ${pct.toFixed(1)}%` : `β ${pct.toFixed(1)}%`;
|
| 1203 |
+
const tipBorder = isHigh ? '#ffb4ab' : '#00ff9c';
|
| 1204 |
return `
|
| 1205 |
+
<div class="tl-bar" style="height:${maxH}px;position:relative;flex:1;min-width:6px;max-width:20px;">
|
| 1206 |
+
<div class="tl-tooltip" style="border-color:${tipBorder};">${tipLabel}</div>
|
| 1207 |
+
<div style="position:absolute;bottom:0;left:1px;right:1px;height:${barH}px;background:${barBg};${barGlow}border-radius:2px 2px 0 0;"></div>
|
| 1208 |
</div>`;
|
| 1209 |
}).join('');
|
| 1210 |
|
|
|
|
| 1217 |
|
| 1218 |
// ββ Audio section βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1219 |
function renderAudio(audio) {
|
| 1220 |
+
const isAI = audio.result === 'AI_VOICE';
|
| 1221 |
+
const isMismatch = audio.result === 'AV_MISMATCH';
|
| 1222 |
+
const color = isMismatch ? '#ffdd65' : (isAI ? '#ffb4ab' : '#00ff9c');
|
| 1223 |
|
| 1224 |
const icon = document.getElementById('audioIcon');
|
| 1225 |
+
icon.textContent = isMismatch ? 'warning' : (isAI ? 'smart_toy' : 'record_voice_over');
|
| 1226 |
+
icon.style.color = color;
|
| 1227 |
|
| 1228 |
const verdict = document.getElementById('audioVerdict');
|
| 1229 |
+
verdict.textContent = isMismatch ? 'β AV MISMATCH' : (audio.result || '--');
|
| 1230 |
+
verdict.style.color = color;
|
| 1231 |
|
| 1232 |
document.getElementById('audioConfidence').textContent =
|
| 1233 |
+
isMismatch
|
| 1234 |
+
? 'Face-swap: voice is human but face is manipulated'
|
| 1235 |
+
: `Confidence: ${typeof audio.confidence === 'number' ? audio.confidence.toFixed(1) + '%' : '--'}`;
|
| 1236 |
|
| 1237 |
const modelScore = typeof audio.model_score === 'number' ? audio.model_score : 0;
|
| 1238 |
const heurScore = typeof audio.heuristic_score === 'number' ? audio.heuristic_score : 0;
|
|
|
|
| 1242 |
document.getElementById('audioHeuristicBar').style.width = heurScore + '%';
|
| 1243 |
document.getElementById('audioHeuristicScore').textContent = heurScore.toFixed(1) + '%';
|
| 1244 |
|
| 1245 |
+
// Audio details β highlight mismatch details in yellow
|
| 1246 |
const detailsEl = document.getElementById('audioDetails');
|
| 1247 |
if (audio.details && audio.details.length) {
|
| 1248 |
detailsEl.innerHTML = audio.details.map(d => `
|
| 1249 |
+
<div class="flex items-start gap-3 p-3 bg-surface-container-low/50 border border-outline-variant/30 rounded-DEFAULT"
|
| 1250 |
+
style="${isMismatch ? 'border-color:rgba(255,221,101,0.3);background:rgba(255,221,101,0.05);' : ''}">
|
| 1251 |
+
<span class="material-symbols-outlined text-[16px] mt-0.5" style="color:${color};">${isMismatch ? 'warning' : 'chevron_right'}</span>
|
| 1252 |
+
<span class="font-data-mono text-data-mono text-sm" style="color:${isMismatch ? '#ffdd65cc' : '#b9cbbc'};">${escHtml(d)}</span>
|
| 1253 |
</div>`).join('');
|
| 1254 |
} else {
|
| 1255 |
detailsEl.innerHTML = '';
|