OpenFakeDemo / app /static /index.html
vicliv's picture
added support for gifs
2b84807
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Deepfake detector</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<style>
html, body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
body { background: #f8fafc; color: #0f172a; }
.arc-fg { transition: stroke-dashoffset 900ms cubic-bezier(0.22, 1, 0.36, 1); }
.drop-active { border-color: #1d4ed8; background-color: #eff6ff; }
.fade-in { animation: fadeIn 350ms ease-out both; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body class="min-h-screen">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<header class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-9 h-9 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold">OF</div>
<span class="font-bold text-lg" data-i18n="title">Deepfake detector</span>
</div>
<div class="inline-flex items-center rounded-full border border-gray-200 bg-white p-1 text-sm">
<button id="lang-en" class="px-3 py-1 rounded-full font-semibold">EN</button>
<button id="lang-fr" class="px-3 py-1 rounded-full font-semibold">FR</button>
</div>
</header>
<main class="mt-10 sm:mt-16">
<section class="text-center max-w-2xl mx-auto">
<h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight" data-i18n="title">Deepfake detector</h1>
<p class="mt-4 text-gray-600 text-lg" data-i18n="subtitle">Upload an image or short video to check if it's AI-generated.</p>
</section>
<section id="card-root" class="mt-10">
<!-- Upload zone -->
<div id="upload-card" class="bg-white rounded-2xl border border-gray-200 p-6 sm:p-10 shadow-sm">
<label id="dropzone" for="file-input"
class="block border-2 border-dashed border-gray-300 rounded-xl p-10 text-center cursor-pointer hover:border-blue-400 transition-colors">
<input id="file-input" type="file" class="hidden"
accept="image/jpeg,image/png,image/webp,image/gif,video/mp4,video/quicktime,video/webm" />
<div id="upload-prompt">
<div class="mx-auto w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M12 12V4m0 0l-4 4m4-4l4 4"/>
</svg>
</div>
<div class="text-base font-semibold text-gray-900" data-i18n="upload_cta">Choose a file or drag and drop</div>
<div class="text-sm text-gray-500 mt-1" data-i18n="upload_hint">JPG, PNG, MP4 up to 100 MB</div>
</div>
<div id="preview" class="hidden">
<div id="preview-media" class="mx-auto mb-4 max-h-64 flex justify-center"></div>
<div id="preview-filename" class="text-sm font-medium text-gray-900 truncate"></div>
<div id="preview-size" class="text-xs text-gray-500 mt-1"></div>
</div>
</label>
<div id="error-banner" class="hidden mt-4 rounded-lg bg-red-50 border border-red-200 text-red-700 px-4 py-3 text-sm"></div>
<div class="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
<button id="analyze-btn" disabled
class="px-6 py-3 rounded-xl bg-blue-600 text-white font-semibold disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors">
<span data-i18n="analyze">Analyze</span>
</button>
<button id="reset-btn" class="hidden px-6 py-3 rounded-xl border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
<span data-i18n="clear">Clear</span>
</button>
</div>
</div>
<!-- Loading -->
<div id="loading-card" class="hidden bg-white rounded-2xl border border-gray-200 p-10 shadow-sm text-center">
<div class="mx-auto w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
<div id="loading-text" class="mt-5 text-gray-700 font-medium" data-i18n="analyzing">Analyzing...</div>
</div>
<!-- Result -->
<div id="result-card" class="hidden bg-white rounded-2xl border border-gray-200 p-6 sm:p-10 shadow-sm fade-in">
<div class="flex items-start justify-between flex-wrap gap-2">
<div>
<div class="text-sm font-semibold text-gray-900 flex items-center gap-1.5">
<span data-i18n="ai_score">AI-generated score</span>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<button id="how-link" class="mt-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-700">
<span data-i18n="how_calculated">How is this calculated?</span>
</button>
</div>
</div>
<div class="mt-8 grid grid-cols-1 lg:grid-cols-3 gap-8 items-center">
<div class="flex flex-col items-center">
<div class="relative w-48 h-48 sm:w-56 sm:h-56">
<svg viewBox="0 0 200 200" class="w-full h-full -rotate-90">
<circle cx="100" cy="100" r="80" fill="none" stroke="#e5e7eb" stroke-width="14" stroke-linecap="round"/>
<circle id="arc-fg" cx="100" cy="100" r="80" fill="none" stroke="#dc2626" stroke-width="14" stroke-linecap="round"
stroke-dasharray="502.65" stroke-dashoffset="502.65" class="arc-fg"/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<div class="text-xs uppercase tracking-wider text-gray-500 font-semibold" data-i18n="ai_label">AI-generated</div>
<div id="score-pct" class="text-4xl sm:text-5xl font-extrabold mt-1 text-gray-900">--%</div>
</div>
</div>
</div>
<div>
<div id="verdict-text" class="text-3xl sm:text-4xl font-extrabold leading-tight"></div>
<div id="advice-text" class="mt-3 text-lg sm:text-xl font-semibold text-gray-900"></div>
<div id="frames-info" class="mt-4 text-sm text-gray-500"></div>
</div>
<div id="preview-pane" style="display: none;" class="flex-col items-center">
<div id="preview-wrap" class="relative inline-block">
<img id="result-image" class="max-h-64 max-w-full rounded-lg block bg-gray-50" alt="" />
<svg id="result-overlay" class="absolute top-0 left-0 w-full h-full pointer-events-none" preserveAspectRatio="none"></svg>
</div>
<div id="preview-status" class="mt-3 text-xs text-gray-500 text-center"></div>
</div>
</div>
<div class="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
<button id="analyze-another-btn"
class="px-6 py-3 rounded-xl border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
<span data-i18n="analyze_another">Analyze another file</span>
</button>
<button id="report-btn"
class="px-6 py-3 rounded-xl border border-amber-300 bg-amber-50 text-amber-800 font-semibold hover:bg-amber-100 transition-colors flex items-center gap-2 justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
<span data-i18n="report_error">Signal an error</span>
</button>
</div>
</div>
</section>
<!-- Disclaimer -->
<footer class="mt-16 max-w-2xl mx-auto">
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-gray-400 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-sm text-gray-500 space-y-2">
<p data-i18n="privacy_note">Files are processed in memory and not stored.</p>
<p class="font-medium text-gray-600" data-i18n="disclaimer_mistakes">Results are probabilistic and should not be treated as definitive.</p>
<p class="font-medium text-gray-600" data-i18n="disclaimer_known_issues_title">Known limitations:</p>
<ul class="list-disc list-inside space-y-1 text-gray-500">
<li data-i18n="disclaimer_full_gen">Performance is strongest on fully AI-generated images.</li>
<li data-i18n="disclaimer_lipsync">Subtle manipulations such as lip sync and localized inpainting may not be reliably detected.</li>
<li data-i18n="disclaimer_text_overlay">Images with text overlays are frequently misclassified as AI-generated.</li>
<li data-i18n="disclaimer_motion_blur">Video analysis may be less accurate in scenes with heavy motion blur.</li>
<li data-i18n="disclaimer_realistic_only">The model is better on realistic images and can fail on non-AI art, 3D models, or drawings.</li>
</ul>
</div>
</div>
</div>
</footer>
</main>
</div>
<!-- How-it-works Modal -->
<div id="modal-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl">
<h2 id="modal-title" class="text-xl font-bold" data-i18n="how_calculated_title">How the score is computed</h2>
<p id="modal-body" class="mt-3 text-gray-700 leading-relaxed" data-i18n="how_calculated_body"></p>
<div class="mt-5 text-right">
<button id="modal-close" class="px-4 py-2 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-700">
<span data-i18n="close">Close</span>
</button>
</div>
</div>
</div>
<!-- Report Error Modal -->
<div id="report-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
<div id="report-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
<div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl my-8">
<h2 class="text-xl font-bold" data-i18n="report_title">Signal an error</h2>
<p class="mt-2 text-sm text-gray-500" data-i18n="report_description">Help us improve by reporting an incorrect result.</p>
<form id="report-form" class="mt-6 space-y-6">
<!-- Is the content real or AI? -->
<fieldset>
<legend class="text-sm font-semibold text-gray-900" data-i18n="report_is_real_label">Is this content real or AI-generated?</legend>
<div class="mt-3 space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="is_real" value="real" class="w-4 h-4 text-blue-600" required />
<span data-i18n="report_real">Real (authentic)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="is_real" value="ai" class="w-4 h-4 text-blue-600" />
<span data-i18n="report_ai">AI-generated or manipulated</span>
</label>
</div>
</fieldset>
<!-- Reason -->
<fieldset>
<legend class="text-sm font-semibold text-gray-900" data-i18n="report_reason_label">How do you know?</legend>
<div class="mt-3 space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reason" value="self_made" class="w-4 h-4 text-blue-600" required />
<span data-i18n="report_reason_self">I created or captured this content myself</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reason" value="evidence" class="w-4 h-4 text-blue-600" />
<span data-i18n="report_reason_evidence">Visible evidence of manipulation (e.g. artifacts, distortions)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reason" value="known_source" class="w-4 h-4 text-blue-600" />
<span data-i18n="report_reason_source">Known and verifiable original source</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reason" value="professional" class="w-4 h-4 text-blue-600" />
<span data-i18n="report_reason_professional">Professional or domain expertise</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reason" value="forensic" class="w-4 h-4 text-blue-600" />
<span data-i18n="report_reason_forensic">Metadata or forensic analysis</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reason" value="reverse_search" class="w-4 h-4 text-blue-600" />
<span data-i18n="report_reason_reverse_search">Reverse image/video search</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reason" value="other" class="w-4 h-4 text-blue-600" />
<span data-i18n="report_reason_other">Other</span>
</label>
</div>
<div id="reason-other-wrap" class="hidden mt-3">
<input id="reason-other-input" type="text" placeholder="" data-i18n-placeholder="report_reason_other_placeholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
</div>
<div id="reason-details-wrap" class="hidden mt-3">
<label class="text-xs font-medium text-gray-600" id="reason-details-label" data-i18n="report_details_label">Please provide details</label>
<input id="reason-details-input" type="text" placeholder="" data-i18n-placeholder="report_details_placeholder"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
</div>
</fieldset>
<!-- Comment -->
<div>
<label class="text-sm font-semibold text-gray-900" data-i18n="report_comment_label">Additional comments (optional)</label>
<textarea id="report-comment" rows="3"
class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none"></textarea>
</div>
<!-- Consent -->
<label class="flex items-start gap-3 cursor-pointer">
<input id="report-consent" type="checkbox" class="w-4 h-4 mt-0.5 text-blue-600 rounded" />
<span class="text-sm text-gray-700" data-i18n="report_consent">I allow saving this file for research purposes. This is required to submit the report.</span>
</label>
<!-- Error / success banners -->
<div id="report-error" class="hidden rounded-lg bg-red-50 border border-red-200 text-red-700 px-4 py-3 text-sm"></div>
<div id="report-success" class="hidden rounded-lg bg-green-50 border border-green-200 text-green-700 px-4 py-3 text-sm" data-i18n="report_success">Thank you! Your report has been submitted.</div>
<!-- Buttons -->
<div class="flex gap-3 justify-end">
<button type="button" id="report-cancel"
class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
<span data-i18n="report_cancel">Cancel</span>
</button>
<button type="submit" id="report-submit"
class="px-4 py-2 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
<span data-i18n="report_submit">Submit report</span>
</button>
</div>
</form>
</div>
</div>
<script>
const I18N = {
en: {
title: "Deepfake detector",
subtitle: "Upload an image, GIF, or short video to check if it's AI-generated.",
upload_cta: "Choose a file or drag and drop",
upload_hint: "JPG, PNG, GIF, MP4 up to 100 MB",
analyze: "Analyze",
clear: "Clear",
analyzing: "Analyzing...",
analyzing_video: "Sampling frames and running the model...",
ai_score: "AI-generated score",
how_calculated: "How is this calculated?",
ai_label: "AI-generated",
verdict_ai_image: "This image is likely AI-generated,",
verdict_ai_video: "This video is likely AI-generated,",
verdict_ai_gif: "This GIF is likely AI-generated,",
verdict_uncertain_image: "We're uncertain about this image,",
verdict_uncertain_video: "We're uncertain about this video,",
verdict_uncertain_gif: "We're uncertain about this GIF,",
verdict_real_image: "This image is likely authentic,",
verdict_real_video: "This video is likely authentic,",
verdict_real_gif: "This GIF is likely authentic,",
advice_ai: "you should not share it with your network.",
advice_uncertain: "verify it from trusted sources before sharing.",
advice_real: "but always cross-check important content.",
analyze_another: "Analyze another file",
error_generic: "Something went wrong. Please try again.",
error_size: "File is too large.",
error_type: "Unsupported file type.",
frames_info: "Averaged over {n} frames.",
preview_cropped_one: "Focused on 1 region (screenshot detected)",
preview_cropped_many: "Focused on {n} regions (scores averaged)",
preview_full: "Full image analyzed",
preview_text_only: "Text-only screenshot — score softened",
how_calculated_title: "How the score is computed",
how_calculated_body: "We use a Swin Transformer V2 model fine-tuned to distinguish real photographs from AI-generated images. For videos and GIFs, we sample 5 frames evenly across the duration and average the model's confidence. The score shown is the model's estimated probability that the content was generated by AI.",
close: "Close",
privacy_note: "Files are processed in memory and not stored.",
disclaimer_mistakes: "The detector can make mistakes. Results are probabilistic and should not be treated as ground truth.",
disclaimer_known_issues_title: "Known limitations:",
disclaimer_full_gen: "Performance is strongest on fully AI-generated images.",
disclaimer_lipsync: "Subtle manipulations such as lip sync and localized inpainting may not be reliably detected.",
disclaimer_text_overlay: "Images with text overlays are frequently misclassified as AI-generated.",
disclaimer_motion_blur: "Video analysis may be less accurate in scenes with heavy motion blur.",
disclaimer_realistic_only: "The model is better on realistic images and can fail on non-AI art, 3D models, or drawings.",
report_error: "Signal an error",
report_title: "Signal an error",
report_description: "Help us improve by reporting an incorrect result.",
report_is_real_label: "Is this content real or AI-generated?",
report_real: "Real (authentic)",
report_ai: "AI-generated or manipulated",
report_reason_label: "How do you know?",
report_reason_self: "I created or captured this content myself",
report_reason_evidence: "Visible evidence of manipulation (e.g. artifacts, distortions)",
report_reason_source: "Known and verifiable original source",
report_reason_professional: "Professional or domain expertise",
report_reason_forensic: "Metadata or forensic analysis",
report_reason_reverse_search: "Reverse image/video search",
report_reason_other: "Other",
report_reason_other_placeholder: "Please specify…",
report_details_label: "Please provide details",
report_details_placeholder: "e.g. source URL, description of artifacts, tool used…",
report_details_label_self_made: "What tool or device did you use?",
report_details_label_evidence: "Describe the evidence you observed",
report_details_label_source: "Provide the source URL or reference",
report_details_label_professional: "Describe your area of expertise",
report_details_label_forensic: "Describe the analysis performed",
report_details_label_reverse_search: "Provide the search tool or URL used",
report_comment_label: "Additional comments (optional)",
report_consent: "I allow saving this file for research purposes. This is required to submit the report.",
report_cancel: "Cancel",
report_submit: "Submit report",
report_submitting: "Submitting...",
report_success: "Thank you! Your report has been submitted.",
report_error_consent: "You must allow saving the file to submit a report.",
report_error_fields: "Please fill in all required fields.",
report_error_generic: "Failed to submit the report. Please try again.",
},
fr: {
title: "Détecteur d'hypertrucage",
subtitle: "Téléversez une image, un GIF ou une courte vidéo pour vérifier si elle est générée par IA.",
upload_cta: "Choisissez un fichier ou glissez-déposez",
upload_hint: "JPG, PNG, GIF, MP4 jusqu'à 100 Mo",
analyze: "Analyser",
clear: "Effacer",
analyzing: "Analyse en cours...",
analyzing_video: "Échantillonnage des images et exécution du modèle...",
ai_score: "Score d'IA générée",
how_calculated: "Comment est-ce calculé ?",
ai_label: "IA générée",
verdict_ai_image: "Cette image est probablement générée par IA,",
verdict_ai_video: "Cette vidéo est probablement générée par IA,",
verdict_ai_gif: "Ce GIF est probablement généré par IA,",
verdict_uncertain_image: "Nous ne sommes pas certains pour cette image,",
verdict_uncertain_video: "Nous ne sommes pas certains pour cette vidéo,",
verdict_uncertain_gif: "Nous ne sommes pas certains pour ce GIF,",
verdict_real_image: "Cette image est probablement authentique,",
verdict_real_video: "Cette vidéo est probablement authentique,",
verdict_real_gif: "Ce GIF est probablement authentique,",
advice_ai: "vous ne devriez pas la partager avec votre réseau.",
advice_uncertain: "vérifiez auprès de sources fiables avant de partager.",
advice_real: "vérifiez tout de même les contenus importants.",
analyze_another: "Analyser un autre fichier",
error_generic: "Une erreur est survenue. Veuillez réessayer.",
error_size: "Le fichier est trop volumineux.",
error_type: "Type de fichier non pris en charge.",
frames_info: "Moyenne sur {n} images.",
preview_cropped_one: "Focus sur 1 zone (capture d'écran détectée)",
preview_cropped_many: "Focus sur {n} zones (scores moyennés)",
preview_full: "Image entière analysée",
preview_text_only: "Capture texte uniquement — score atténué",
how_calculated_title: "Comment le score est calculé",
how_calculated_body: "Nous utilisons un modèle Swin Transformer V2 entraîné pour distinguer les vraies photographies des images générées par IA. Pour les vidéos et les GIFs, nous échantillonnons 5 images réparties uniformément sur la durée et faisons la moyenne de la confiance du modèle. Le score affiché correspond à la probabilité estimée que le contenu ait été généré par IA.",
close: "Fermer",
privacy_note: "Les fichiers sont traités en mémoire et ne sont pas conservés.",
disclaimer_mistakes: "Le détecteur peut faire des erreurs. Les résultats sont probabilistes et ne doivent pas être considérés comme une vérité absolue.",
disclaimer_known_issues_title: "Limitations connues :",
disclaimer_full_gen: "Les performances sont optimales sur les images entièrement générées par IA.",
disclaimer_lipsync: "Les manipulations subtiles telles que la synchronisation labiale et l'inpainting localisé peuvent ne pas être détectées de manière fiable.",
disclaimer_text_overlay: "Les images comportant du texte superposé sont fréquemment classées à tort comme générées par IA.",
disclaimer_motion_blur: "L'analyse vidéo peut être moins précise dans les scènes présentant un fort flou de mouvement.",
disclaimer_realistic_only: "Le modèle est plus performant sur les images réalistes et peut échouer sur les œuvres d'art non générées par IA, les modèles 3D ou les dessins.",
report_error: "Signaler une erreur",
report_title: "Signaler une erreur",
report_description: "Aidez-nous à nous améliorer en signalant un résultat incorrect.",
report_is_real_label: "Ce contenu est-il réel ou généré par IA ?",
report_real: "Réel (authentique)",
report_ai: "Généré par IA ou manipulé",
report_reason_label: "Comment le savez-vous ?",
report_reason_self: "J'ai créé ou capturé ce contenu moi-même",
report_reason_evidence: "Preuves visibles de manipulation (ex. : artefacts, distorsions)",
report_reason_source: "Source originale connue et vérifiable",
report_reason_professional: "Expertise professionnelle ou de domaine",
report_reason_forensic: "Analyse des métadonnées ou analyse forensique",
report_reason_reverse_search: "Recherche inversée d'image ou de vidéo",
report_reason_other: "Autre",
report_reason_other_placeholder: "Veuillez préciser…",
report_details_label: "Veuillez fournir des détails",
report_details_placeholder: "ex. : URL source, description des artefacts, outil utilisé…",
report_details_label_self_made: "Quel outil ou appareil avez-vous utilisé ?",
report_details_label_evidence: "Décrivez les preuves observées",
report_details_label_source: "Fournissez l'URL source ou la référence",
report_details_label_professional: "Décrivez votre domaine d'expertise",
report_details_label_forensic: "Décrivez l'analyse effectuée",
report_details_label_reverse_search: "Indiquez l'outil ou l'URL de recherche utilisé",
report_comment_label: "Commentaires supplémentaires (optionnel)",
report_consent: "J'autorise la sauvegarde de ce fichier à des fins de recherche. Ceci est requis pour soumettre le signalement.",
report_cancel: "Annuler",
report_submit: "Envoyer le signalement",
report_submitting: "Envoi en cours...",
report_success: "Merci ! Votre signalement a été soumis.",
report_error_consent: "Vous devez autoriser la sauvegarde du fichier pour soumettre un signalement.",
report_error_fields: "Veuillez remplir tous les champs obligatoires.",
report_error_generic: "Échec de l'envoi du signalement. Veuillez réessayer.",
},
};
const CIRCUMFERENCE = 2 * Math.PI * 80;
const state = {
lang: localStorage.getItem("lang") || (navigator.language.startsWith("fr") ? "fr" : "en"),
file: null,
result: null,
loading: false,
error: null,
};
const $ = (id) => document.getElementById(id);
function t() {
return I18N[state.lang];
}
function applyI18n() {
document.documentElement.lang = state.lang;
document.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
if (t()[key] != null) el.textContent = t()[key];
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
const key = el.getAttribute("data-i18n-placeholder");
if (t()[key] != null) el.placeholder = t()[key];
});
$("lang-en").className = "px-3 py-1 rounded-full font-semibold " +
(state.lang === "en" ? "bg-blue-600 text-white" : "text-gray-600");
$("lang-fr").className = "px-3 py-1 rounded-full font-semibold " +
(state.lang === "fr" ? "bg-blue-600 text-white" : "text-gray-600");
if (state.result) {
renderResultText();
renderPreviewOverlay();
}
}
function setLang(lang) {
state.lang = lang;
localStorage.setItem("lang", lang);
applyI18n();
}
function getVerdict(aiScore, mediaType) {
const T = t();
const key = mediaType === "video" ? "video" : mediaType === "gif" ? "gif" : "image";
if (aiScore >= 0.60) {
return {
verdict: T[`verdict_ai_${key}`],
advice: T.advice_ai,
tone: "ai",
};
}
if (aiScore >= 0.30) {
return {
verdict: T[`verdict_uncertain_${key}`],
advice: T.advice_uncertain,
tone: "uncertain",
};
}
return {
verdict: T[`verdict_real_${key}`],
advice: T.advice_real,
tone: "real",
};
}
function showPreview(file) {
const previewMedia = $("preview-media");
previewMedia.innerHTML = "";
if (file.type.startsWith("image/")) {
const img = document.createElement("img");
img.src = URL.createObjectURL(file);
img.className = "max-h-64 rounded-lg object-contain";
img.onload = () => URL.revokeObjectURL(img.src);
previewMedia.appendChild(img);
} else {
const video = document.createElement("video");
video.src = URL.createObjectURL(file);
video.className = "max-h-64 rounded-lg";
video.muted = true;
video.playsInline = true;
video.preload = "metadata";
previewMedia.appendChild(video);
}
$("upload-prompt").classList.add("hidden");
$("preview").classList.remove("hidden");
$("preview-filename").textContent = file.name;
$("preview-size").textContent = `${(file.size / (1024 * 1024)).toFixed(2)} MB`;
$("analyze-btn").disabled = false;
$("reset-btn").classList.remove("hidden");
}
function resetUpload() {
state.file = null;
state.error = null;
$("file-input").value = "";
$("upload-prompt").classList.remove("hidden");
$("preview").classList.add("hidden");
$("analyze-btn").disabled = true;
$("reset-btn").classList.add("hidden");
$("error-banner").classList.add("hidden");
const resultImg = $("result-image");
if (resultImg.src) {
try { URL.revokeObjectURL(resultImg.src); } catch (_) {}
resultImg.removeAttribute("src");
}
$("preview-pane").style.display = "none";
}
function showError(msg) {
const banner = $("error-banner");
banner.textContent = msg;
banner.classList.remove("hidden");
}
function setFile(file) {
$("error-banner").classList.add("hidden");
if (!file) return;
const isImage = file.type.startsWith("image/");
const isVideo = file.type.startsWith("video/");
if (!isImage && !isVideo) {
showError(t().error_type);
return;
}
const maxMB = isVideo ? 300 : 50;
if (file.size / (1024 * 1024) > maxMB) {
showError(t().error_size);
return;
}
state.file = file;
showPreview(file);
}
function renderResultText() {
if (!state.result) return;
const score = state.result.p_fake;
const v = getVerdict(score, state.result.media_type);
$("verdict-text").textContent = v.verdict;
$("advice-text").textContent = v.advice;
$("score-pct").textContent = `${Math.round(score * 100)}%`;
const tones = {
ai: "#dc2626",
uncertain: "#d97706",
real: "#16a34a",
};
const color = tones[v.tone];
$("arc-fg").setAttribute("stroke", color);
$("verdict-text").style.color = color;
if (state.result.media_type === "video" || state.result.media_type === "gif") {
$("frames-info").textContent = t().frames_info.replace("{n}", state.result.n_frames);
} else {
$("frames-info").textContent = "";
}
}
function renderPreviewOverlay() {
const pane = $("preview-pane");
const img = $("result-image");
const overlay = $("result-overlay");
const statusEl = $("preview-status");
if (!state.result || state.result.media_type !== "image" || !state.file) {
pane.style.display = "none";
return;
}
if (img.src) {
try { URL.revokeObjectURL(img.src); } catch (_) {}
}
img.src = URL.createObjectURL(state.file);
img.onload = () => {
const [iw, ih] = state.result.image_size || [img.naturalWidth, img.naturalHeight];
overlay.setAttribute("viewBox", `0 0 ${iw} ${ih}`);
// Clear previous rects.
while (overlay.firstChild) overlay.removeChild(overlay.firstChild);
const boxes = state.result.crop_box || [];
const sw = Math.max(iw, ih) * 0.012; // thick stroke, ~1.2% of larger dim
for (const box of boxes) {
const [x, y, w, h] = box;
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", x);
rect.setAttribute("y", y);
rect.setAttribute("width", w);
rect.setAttribute("height", h);
rect.setAttribute("fill", "none");
rect.setAttribute("stroke", "#ef4444");
rect.setAttribute("stroke-width", sw);
rect.setAttribute("rx", sw * 0.5);
overlay.appendChild(rect);
}
};
const T = t();
const status = state.result.preprocess_status;
let label = "";
if (status === "cropped") {
const n = state.result.n_crops || 1;
label = n === 1
? T.preview_cropped_one
: T.preview_cropped_many.replace("{n}", n);
} else if (status === "text_only") {
label = T.preview_text_only;
} else {
label = T.preview_full;
}
statusEl.textContent = label;
pane.style.display = "flex";
}
function animateArc(fraction) {
const arc = $("arc-fg");
arc.style.transition = "none";
arc.setAttribute("stroke-dashoffset", CIRCUMFERENCE);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
arc.style.transition = "";
const target = CIRCUMFERENCE * (1 - Math.max(0, Math.min(1, fraction)));
arc.setAttribute("stroke-dashoffset", target);
});
});
}
function showCard(name) {
["upload-card", "loading-card", "result-card"].forEach((id) => {
$(id).classList.toggle("hidden", id !== name);
});
}
async function analyze() {
if (!state.file) return;
state.loading = true;
state.error = null;
const isVideoOrGif = state.file.type.startsWith("video/") || state.file.type === "image/gif";
$("loading-text").textContent = isVideoOrGif ? t().analyzing_video : t().analyzing;
showCard("loading-card");
const form = new FormData();
form.append("file", state.file);
try {
const res = await fetch("/api/predict", { method: "POST", body: form });
if (!res.ok) {
let detail = t().error_generic;
if (res.status === 413) detail = t().error_size;
else if (res.status === 415) detail = t().error_type;
else {
const body = await res.json().catch(() => ({}));
if (body.detail) detail = body.detail;
}
throw new Error(detail);
}
state.result = await res.json();
renderResultText();
renderPreviewOverlay();
showCard("result-card");
animateArc(state.result.p_fake);
} catch (e) {
showError(e.message);
showCard("upload-card");
} finally {
state.loading = false;
}
}
/* --- How-it-works modal --- */
function openModal() {
$("modal").classList.remove("hidden");
$("modal").classList.add("flex");
$("modal-backdrop").classList.remove("hidden");
}
function closeModal() {
$("modal").classList.add("hidden");
$("modal").classList.remove("flex");
$("modal-backdrop").classList.add("hidden");
}
/* --- Report modal --- */
// Reasons that should show the contextual detail input
const REASONS_WITH_DETAILS = ["self_made", "evidence", "known_source", "professional", "forensic", "reverse_search"];
function updateReasonUI(selectedValue) {
const otherWrap = $("reason-other-wrap");
const detailsWrap = $("reason-details-wrap");
// "Other" free-text
if (selectedValue === "other") {
otherWrap.classList.remove("hidden");
} else {
otherWrap.classList.add("hidden");
}
// Contextual detail input
if (REASONS_WITH_DETAILS.includes(selectedValue)) {
detailsWrap.classList.remove("hidden");
const T = t();
const labelKey = "report_details_label_" + selectedValue;
const label = T[labelKey] || T.report_details_label;
$("reason-details-label").textContent = label;
} else {
detailsWrap.classList.add("hidden");
}
}
function openReportModal() {
// Reset the form
$("report-form").reset();
$("reason-other-wrap").classList.add("hidden");
$("reason-details-wrap").classList.add("hidden");
$("report-error").classList.add("hidden");
$("report-success").classList.add("hidden");
$("report-submit").disabled = false;
$("report-submit").querySelector("[data-i18n]").textContent = t().report_submit;
// Show modal
$("report-modal").classList.remove("hidden");
$("report-modal").classList.add("flex");
$("report-backdrop").classList.remove("hidden");
}
function closeReportModal() {
$("report-modal").classList.add("hidden");
$("report-modal").classList.remove("flex");
$("report-backdrop").classList.add("hidden");
}
async function submitReport(e) {
e.preventDefault();
const T = t();
const errorEl = $("report-error");
const successEl = $("report-success");
errorEl.classList.add("hidden");
successEl.classList.add("hidden");
// Validate consent
if (!$("report-consent").checked) {
errorEl.textContent = T.report_error_consent;
errorEl.classList.remove("hidden");
return;
}
// Gather radio values
const isRealRadio = document.querySelector('input[name="is_real"]:checked');
const reasonRadio = document.querySelector('input[name="reason"]:checked');
if (!isRealRadio || !reasonRadio) {
errorEl.textContent = T.report_error_fields;
errorEl.classList.remove("hidden");
return;
}
// If reason is "other", require the text input
const reasonValue = reasonRadio.value;
const reasonOther = $("reason-other-input").value.trim();
if (reasonValue === "other" && !reasonOther) {
errorEl.textContent = T.report_error_fields;
errorEl.classList.remove("hidden");
return;
}
// Build form data
const formData = new FormData();
formData.append("file", state.file);
formData.append("is_real", isRealRadio.value);
formData.append("reason", reasonValue);
formData.append("reason_other", reasonOther);
formData.append("reason_details", $("reason-details-input").value.trim());
formData.append("comment", $("report-comment").value.trim());
formData.append("p_fake", state.result ? state.result.p_fake : 0);
formData.append("consent", "true");
// Disable button and show loading
const submitBtn = $("report-submit");
submitBtn.disabled = true;
submitBtn.querySelector("[data-i18n]").textContent = T.report_submitting;
try {
const res = await fetch("/api/report", { method: "POST", body: formData });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || T.report_error_generic);
}
successEl.textContent = T.report_success;
successEl.classList.remove("hidden");
// Auto-close after 2s
setTimeout(() => closeReportModal(), 2000);
} catch (err) {
errorEl.textContent = err.message || T.report_error_generic;
errorEl.classList.remove("hidden");
submitBtn.disabled = false;
submitBtn.querySelector("[data-i18n]").textContent = T.report_submit;
}
}
function init() {
applyI18n();
$("lang-en").addEventListener("click", () => setLang("en"));
$("lang-fr").addEventListener("click", () => setLang("fr"));
const fileInput = $("file-input");
fileInput.addEventListener("change", (e) => {
const f = e.target.files && e.target.files[0];
if (f) setFile(f);
});
const dz = $("dropzone");
["dragenter", "dragover"].forEach((evt) => {
dz.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.add("drop-active");
});
});
["dragleave", "drop"].forEach((evt) => {
dz.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
dz.classList.remove("drop-active");
});
});
dz.addEventListener("drop", (e) => {
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) setFile(f);
});
$("analyze-btn").addEventListener("click", analyze);
$("reset-btn").addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
resetUpload();
});
$("analyze-another-btn").addEventListener("click", () => {
state.result = null;
resetUpload();
showCard("upload-card");
});
// How-it-works modal
$("how-link").addEventListener("click", openModal);
$("modal-close").addEventListener("click", closeModal);
$("modal-backdrop").addEventListener("click", closeModal);
// Report modal
$("report-btn").addEventListener("click", openReportModal);
$("report-cancel").addEventListener("click", closeReportModal);
$("report-backdrop").addEventListener("click", closeReportModal);
$("report-form").addEventListener("submit", submitReport);
// Toggle reason sub-fields (other text input + contextual details)
document.querySelectorAll('input[name="reason"]').forEach((radio) => {
radio.addEventListener("change", () => {
if (radio.checked) updateReasonUI(radio.value);
});
});
// Escape key closes any open modal
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeModal();
closeReportModal();
}
});
}
init();
</script>
</body>
</html>