Spaces:
Sleeping
Sleeping
| <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> | |