// VeriSight browser engine — pure, dependency-free analysis functions. // Mirrors the Python analyzers in app/ so both editions behave the same. // Works in browsers and in Node (see web/tests). No DOM access in this file. export const ENGINE_VERSION = "1.1.0-web"; // ------------------------------------------------------------------ helpers const clamp = (x, lo, hi) => Math.min(Math.max(x, lo), hi); const logit = (p) => { p = clamp(p, 1e-3, 1 - 1e-3); return Math.log(p / (1 - p)); }; const round = (x, d = 3) => Math.round(x * 10 ** d) / 10 ** d; /** Decode bytes as latin1 so binary-safe substring search works. */ function latin1(bytes) { let s = ""; const CH = 0x8000; for (let i = 0; i < bytes.length; i += CH) s += String.fromCharCode.apply(null, bytes.subarray(i, i + CH)); return s; } /** Only scan the head and tail of large files (metadata lives there). */ export function scanWindow(bytes, head = 4 << 20, tail = 1 << 20) { if (bytes.length <= head + tail) return bytes; const out = new Uint8Array(head + tail); out.set(bytes.subarray(0, head), 0); out.set(bytes.subarray(bytes.length - tail), head); return out; } // ------------------------------------------------------------------ metadata export const GENERATOR_MARKERS = [ ["trainedalgorithmicmedia", "IPTC digital source type: AI-generated (trainedAlgorithmicMedia)"], ["compositewithtrainedalgorithmicmedia", "IPTC digital source type: composite with AI content"], ["stable diffusion", "Stable Diffusion"], ["stablediffusion", "Stable Diffusion"], ["comfyui", "ComfyUI"], ["automatic1111", "AUTOMATIC1111 WebUI"], ["midjourney", "Midjourney"], ["dall-e", "DALL-E"], ["dall·e", "DALL-E"], ["openai", "OpenAI"], ["adobe firefly", "Adobe Firefly"], ["made with google ai", "Google AI (Imagen / Gemini)"], ["novelai", "NovelAI"], ["leonardo.ai", "Leonardo.Ai"], ["ideogram", "Ideogram"], ["black forest labs", "FLUX (Black Forest Labs)"], ["invokeai", "InvokeAI"], ["fooocus", "Fooocus"], ["runwayml", "Runway"], ["synthesia", "Synthesia"], ["heygen", "HeyGen"], ]; const GENERATOR_TEXT_KEYS = new Set(["parameters", "prompt", "workflow", "sd-metadata", "invokeai_metadata", "dream"]); const EDITING_SOFTWARE = ["photoshop", "gimp", "lightroom", "affinity", "pixelmator", "snapseed", "facetune", "canva"]; const IPTC_LABELS = new Set([GENERATOR_MARKERS[0][1], GENERATOR_MARKERS[1][1]]); export function scanGeneratorMarkers(bytes) { const text = latin1(scanWindow(bytes)).toLowerCase(); const found = []; for (const [marker, label] of GENERATOR_MARKERS) { if (text.includes(marker) && !found.includes(label)) found.push(label); } if (found.includes(GENERATOR_MARKERS[1][1])) return found.filter((f) => f !== GENERATOR_MARKERS[0][1]); return found; } /** Keywords of PNG tEXt / iTXt / zTXt chunks. */ export function pngTextKeys(bytes) { const sig = [137, 80, 78, 71, 13, 10, 26, 10]; if (bytes.length < 8 || sig.some((b, i) => bytes[i] !== b)) return []; const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const keys = []; let p = 8; while (p + 8 <= bytes.length) { const len = dv.getUint32(p); const type = String.fromCharCode(...bytes.subarray(p + 4, p + 8)); if (type === "tEXt" || type === "iTXt" || type === "zTXt") { const data = bytes.subarray(p + 8, Math.min(p + 8 + len, bytes.length)); const nul = data.indexOf(0); if (nul > 0) keys.push(String.fromCharCode(...data.subarray(0, Math.min(nul, 79)))); } if (type === "IEND") break; p += 12 + len; } return keys; } /** Minimal EXIF reader for JPEG: Make, Model, Software, DateTimeOriginal, ExposureTime, FNumber, GPS. */ export function readExif(bytes) { const out = {}; if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return out; const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let p = 2; while (p + 4 < bytes.length && bytes[p] === 0xff) { const marker = bytes[p + 1]; const size = dv.getUint16(p + 2); if (marker === 0xe1 && latin1(bytes.subarray(p + 4, p + 10)) === "Exif\0\0") { try { parseTiff(dv, p + 10, out); } catch { /* corrupt EXIF never blocks analysis */ } break; } if (marker === 0xda) break; // start of scan p += 2 + size; } return out; } const TAGS = { 0x010f: "Make", 0x0110: "Model", 0x0131: "Software", 0x9003: "DateTimeOriginal", 0x829a: "ExposureTime", 0x829d: "FNumber", 0x013b: "Artist" }; function parseTiff(dv, base, out) { const le = dv.getUint16(base) === 0x4949; const u16 = (o) => dv.getUint16(base + o, le); const u32 = (o) => dv.getUint32(base + o, le); const readIfd = (off) => { const n = u16(off); let exifPtr = 0; for (let i = 0; i < n; i++) { const e = off + 2 + i * 12; const tag = u16(e), type = u16(e + 2), count = u32(e + 4); const valOff = count * ([0, 1, 1, 2, 4, 8][type] || 1) > 4 ? u32(e + 8) : e + 8; if (tag === 0x8769) exifPtr = u32(e + 8); else if (tag === 0x8825) out.GPS = "present"; else if (TAGS[tag]) { if (type === 2) { let s = ""; for (let k = 0; k < Math.min(count, 120); k++) { const c = dv.getUint8(base + valOff + k); if (!c) break; s += String.fromCharCode(c); } out[TAGS[tag]] = s.trim(); } else if (type === 5) out[TAGS[tag]] = String(round(u32(valOff) / (u32(valOff + 4) || 1), 4)); } } return exifPtr; }; const exifPtr = readIfd(u32(4)); if (exifPtr) readIfd(exifPtr); } export function analyzeMetadata(bytes, { dropIptc = false } = {}) { let markers = scanGeneratorMarkers(bytes); if (dropIptc) markers = markers.filter((m) => !IPTC_LABELS.has(m)); const textKeys = pngTextKeys(bytes).filter((k) => GENERATOR_TEXT_KEYS.has(k.toLowerCase())); if (textKeys.length) markers.push(`Local generator metadata chunk (${textKeys.join(", ")})`); const exif = readExif(bytes); const details = { exif, generator_markers: markers }; const base = { id: "metadata", name: "Metadata & generator fingerprints", category: "metadata" }; if (markers.length) return { ...base, status: "ok", score: 0.97, weight: 3.0, details, summary: `The file's metadata declares an AI generator: ${markers.slice(0, 3).join("; ")}.` }; const software = (exif.Software || "").toLowerCase(); if (EDITING_SOFTWARE.some((t) => software.includes(t))) return { ...base, status: "ok", score: 0.55, weight: 0.3, details, summary: `Edited with ${exif.Software}. Editing is not proof of fakery, but the image is not straight from a camera.` }; if ((exif.Make || exif.Model) && (exif.DateTimeOriginal || exif.ExposureTime || exif.FNumber)) return { ...base, status: "ok", score: 0.3, weight: 0.6, details, summary: `Camera metadata present (${[exif.Make, exif.Model].filter(Boolean).join(" ")}). Consistent with a real photo, though metadata can be forged.` }; return { ...base, status: "neutral", score: null, weight: 0, details, summary: "No camera or generator metadata. Common for screenshots and social-media uploads, which strip it." }; } // ------------------------------------------------------------------ provenance (C2PA) /** Detects embedded C2PA Content Credentials and the digital source types they declare. * The browser edition reads the manifest but does not verify its signature. */ export function analyzeProvenance(bytes) { const base = { id: "provenance", name: "Content Credentials (C2PA)", category: "provenance" }; const text = latin1(scanWindow(bytes)); const lower = text.toLowerCase(); const present = lower.includes("c2pa") && (text.includes("jumb") || text.includes("caBX") || lower.includes("c2pa.claim")); if (!present) return { ...base, status: "neutral", score: null, weight: 0, details: {}, summary: "No Content Credentials attached. Most media doesn't carry them yet, so this is neither good nor bad." }; const sources = [...new Set([...text.matchAll(/digitalsourcetype\/([A-Za-z]+)/gi)].map((m) => m[1]))]; const gen = text.match(/claim_generator[^A-Za-z0-9]{1,12}([A-Za-z0-9 ._\/()-]{3,60})/); const details = { digital_source_types: sources, generator: gen ? gen[1].trim() : null, signature_verified: false }; const s = sources.map((x) => x.toLowerCase()); const note = " (Signature not verified in the browser edition.)"; if (s.includes("compositewithtrainedalgorithmicmedia")) return { ...base, status: "ok", score: 0.9, weight: 2.5, details, summary: "Content Credentials declare this media contains AI-generated parts." + note }; if (s.includes("trainedalgorithmicmedia")) return { ...base, status: "ok", score: 0.96, weight: 3.0, details, summary: "Content Credentials declare this media is AI-generated." + note }; if (s.includes("digitalcapture")) return { ...base, status: "ok", score: 0.2, weight: 1.0, details, summary: "Content Credentials say this was captured by a camera." + note }; return { ...base, status: "neutral", score: null, weight: 0, details, summary: "Content Credentials found, but they don't state how the content was created." + note }; } // ------------------------------------------------------------------ colour maps function lut(stops) { const out = new Uint8Array(256 * 3); for (let i = 0; i < 256; i++) { const t = i / 255 * (stops.length - 1), k = Math.min(Math.floor(t), stops.length - 2), f = t - k; for (let c = 0; c < 3; c++) out[i * 3 + c] = Math.round(stops[k][c] + (stops[k + 1][c] - stops[k][c]) * f); } return out; } const INFERNO = lut([[0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164]]); const VIRIDIS = lut([[68, 1, 84], [72, 40, 120], [62, 74, 137], [49, 104, 142], [38, 130, 142], [31, 158, 137], [53, 183, 121], [109, 205, 89], [180, 222, 44], [253, 231, 37]]); // ------------------------------------------------------------------ error level analysis const BLOCK = 16; /** orig, resaved: RGBA ImageData-like {width, height, data}. resaved = orig re-encoded as JPEG q≈0.9. */ export function computeEla(orig, resaved) { const { width: w, height: h } = orig; const diff = new Float32Array(w * h); for (let i = 0, j = 0; i < diff.length; i++, j += 4) { const a = orig.data, b = resaved.data; diff[i] = Math.max(Math.abs(a[j] - b[j]), Math.abs(a[j + 1] - b[j + 1]), Math.abs(a[j + 2] - b[j + 2])); } const hb = Math.floor(h / BLOCK), wb = Math.floor(w / BLOCK); if (hb < 2 || wb < 2) return { diff, stats: { blocks: 0 } }; const blocks = new Float64Array(hb * wb); for (let y = 0; y < hb * BLOCK; y++) for (let x = 0; x < wb * BLOCK; x++) blocks[Math.floor(y / BLOCK) * wb + Math.floor(x / BLOCK)] += diff[y * w + x]; const n = blocks.length, area = BLOCK * BLOCK; let mean = 0; for (let i = 0; i < n; i++) { blocks[i] /= area; mean += blocks[i]; } mean /= n; let v = 0; for (let i = 0; i < n; i++) v += (blocks[i] - mean) ** 2; const std = Math.sqrt(v / n); let hot = 0; for (let i = 0; i < n; i++) if (blocks[i] > mean + 3 * std) hot++; return { diff, stats: { mean_error: round(mean), block_cv: round(std / (mean + 1e-6)), hotspot_ratio: round(hot / n, 4), blocks: n } }; } export function elaSignal(stats, format) { const base = { id: "ela", name: "Error Level Analysis", category: "forensic" }; if (!stats.blocks) return { ...base, status: "unavailable", score: null, weight: 0, details: stats, summary: "Image too small for ELA." }; const raw = 0.5 + 0.3 * Math.tanh(2 * (stats.block_cv - 0.9)) + Math.min(stats.hotspot_ratio * 6, 0.1); const score = clamp(raw, 0.15, 0.85); let summary; if (score >= 0.62) summary = "Some regions compress very differently from the rest — inspect the bright areas in the heatmap for edits."; else if (score <= 0.38) summary = "No statistical anomaly in compression levels. Still check the heatmap: a pasted area can show up as a patch with its own texture even when the numbers look normal."; else summary = "Compression levels show no strong pattern either way. The heatmap shows where to look."; if (format && format !== "JPEG") summary += ` (ELA is most meaningful for JPEGs; this is ${format}.)`; return { ...base, status: "ok", score: round(score), weight: 0.35, summary, details: { ...stats, source_format: format || null } }; } function percentile(arr, q) { const s = Float32Array.from(arr).sort(); return s[Math.min(s.length - 1, Math.floor((q / 100) * (s.length - 1)))]; } function gaussianBlur(src, w, h, sigma) { const r = Math.ceil(sigma * 3), k = new Float32Array(2 * r + 1); let sum = 0; for (let i = -r; i <= r; i++) { k[i + r] = Math.exp(-(i * i) / (2 * sigma * sigma)); sum += k[i + r]; } for (let i = 0; i < k.length; i++) k[i] /= sum; const tmp = new Float32Array(w * h), out = new Float32Array(w * h); for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { let a = 0; for (let i = -r; i <= r; i++) a += src[y * w + clamp(x + i, 0, w - 1)] * k[i + r]; tmp[y * w + x] = a; } for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { let a = 0; for (let i = -r; i <= r; i++) a += tmp[clamp(y + i, 0, h - 1) * w + x] * k[i + r]; out[y * w + x] = a; } return out; } /** Returns RGBA pixels: original blended with an inferno-coloured error map. */ export function elaHeatmap(orig, diff) { const { width: w, height: h } = orig; const scale = percentile(diff, 99.5) || 1; const norm = new Float32Array(diff.length); for (let i = 0; i < diff.length; i++) norm[i] = clamp((diff[i] / scale) * 255, 0, 255); const blurred = gaussianBlur(norm, w, h, 1.2); const out = new Uint8ClampedArray(w * h * 4); for (let i = 0, j = 0; i < blurred.length; i++, j += 4) { const v = Math.round(clamp(blurred[i], 0, 255)) * 3; out[j] = 0.35 * orig.data[j] + 0.65 * INFERNO[v]; out[j + 1] = 0.35 * orig.data[j + 1] + 0.65 * INFERNO[v + 1]; out[j + 2] = 0.35 * orig.data[j + 2] + 0.65 * INFERNO[v + 2]; out[j + 3] = 255; } return { width: w, height: h, data: out }; } // ------------------------------------------------------------------ frequency spectrum export const FFT_SIZE = 512; /** Centre-crop to a square and area-resample to size×size grayscale in [0,1]. */ export function squareGray(img, size = FFT_SIZE) { const { width: w, height: h, data } = img; const side = Math.min(w, h), x0 = Math.floor((w - side) / 2), y0 = Math.floor((h - side) / 2); const out = new Float32Array(size * size), step = side / size; // OpenCV's fixed-point RGB→GRAY, rounded to uint8 (matters: quantisation shapes the high-frequency spectrum) const lum = (x, y) => { const j = (y * w + x) * 4; return (data[j] * 4899 + data[j + 1] * 9617 + data[j + 2] * 1868 + 8192) >> 14; }; if (side < size) { // enlarging: OpenCV INTER_AREA up-scaling coefficients const inv = size / side; const coef = (d) => { const s0 = Math.floor(d * step); let f = d + 1 - (s0 + 1) * inv; f = f <= 0 ? 0 : f - Math.floor(f); return [s0, Math.min(s0 + 1, side - 1), f]; }; const cols = Array.from({ length: size }, (_, d) => coef(d)); for (let oy = 0; oy < size; oy++) { const [iy, iy1, ty] = coef(oy); for (let ox = 0; ox < size; ox++) { const [ix, ix1, tx] = cols[ox]; const a = lum(x0 + ix, y0 + iy), b = lum(x0 + ix1, y0 + iy), c = lum(x0 + ix, y0 + iy1), d = lum(x0 + ix1, y0 + iy1); out[oy * size + ox] = Math.round((a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty) / 255; } } return out; } for (let oy = 0; oy < size; oy++) { const sy0 = y0 + Math.floor(oy * step), sy1 = Math.max(sy0 + 1, y0 + Math.floor((oy + 1) * step)); for (let ox = 0; ox < size; ox++) { const sx0 = x0 + Math.floor(ox * step), sx1 = Math.max(sx0 + 1, x0 + Math.floor((ox + 1) * step)); let acc = 0, n = 0; for (let y = sy0; y < sy1; y++) for (let x = sx0; x < sx1; x++) { acc += lum(x, y); n++; } out[oy * size + ox] = Math.round(acc / n) / 255; } } return out; } function fft1d(re, im) { const n = re.length; for (let i = 1, j = 0; i < n; i++) { let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } } for (let len = 2; len <= n; len <<= 1) { const ang = (-2 * Math.PI) / len, wr = Math.cos(ang), wi = Math.sin(ang); for (let i = 0; i < n; i += len) { let cr = 1, ci = 0; for (let k = 0; k < len / 2; k++) { const a = i + k, b = a + len / 2; const tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr; re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti; const nr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = nr; } } } } /** log1p |FFT2(windowed, mean-removed gray)|, fft-shifted. */ export function logSpectrum(gray, n = FFT_SIZE) { let mean = 0; for (const v of gray) mean += v; mean /= gray.length; const han = new Float64Array(n); for (let i = 0; i < n; i++) han[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (n - 1)); const re = new Float64Array(n * n), im = new Float64Array(n * n); for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) re[y * n + x] = (gray[y * n + x] - mean) * han[y] * han[x]; const rr = new Float64Array(n), ri = new Float64Array(n); for (let y = 0; y < n; y++) { // rows for (let x = 0; x < n; x++) { rr[x] = re[y * n + x]; ri[x] = im[y * n + x]; } fft1d(rr, ri); for (let x = 0; x < n; x++) { re[y * n + x] = rr[x]; im[y * n + x] = ri[x]; } } for (let x = 0; x < n; x++) { // columns for (let y = 0; y < n; y++) { rr[y] = re[y * n + x]; ri[y] = im[y * n + x]; } fft1d(rr, ri); for (let y = 0; y < n; y++) { re[y * n + x] = rr[y]; im[y * n + x] = ri[y]; } } const mag = new Float32Array(n * n), half = n / 2; for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) { const sy = (y + half) % n, sx = (x + half) % n, i = y * n + x; mag[sy * n + sx] = Math.log1p(Math.hypot(re[i], im[i])); } return mag; } /** 9×9 median filter on uint8 data with replicated borders (Huang's sliding histogram). */ function median9(src, n) { const R = 4, out = new Uint8Array(n * n), hist = new Int32Array(256), half = 41; const at = (y, x) => src[clamp(y, 0, n - 1) * n + clamp(x, 0, n - 1)]; for (let y = 0; y < n; y++) { hist.fill(0); for (let dy = -R; dy <= R; dy++) for (let dx = -R; dx <= R; dx++) hist[at(y + dy, dx)]++; for (let x = 0; x < n; x++) { if (x > 0) for (let dy = -R; dy <= R; dy++) { hist[at(y + dy, x - R - 1)]--; hist[at(y + dy, x + R)]++; } let c = 0, v = 0; for (; v < 256; v++) { c += hist[v]; if (c >= half) break; } out[y * n + x] = v; } } return out; } export function frequencyFeatures(gray, n = FFT_SIZE) { const mag = logSpectrum(gray, n); const c = n / 2, r = new Float32Array(n * n); for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) r[y * n + x] = Math.hypot(y - c, x - c) / c; // radial profile slope (log-log) const B = 40, edges = Array.from({ length: B }, (_, i) => 0.05 + (0.95 * i) / (B - 1)); const sums = new Float64Array(B), counts = new Int32Array(B); for (let i = 0; i < mag.length; i++) { let k = 0; while (k < B && r[i] >= edges[k]) k++; // numpy.digitize if (k >= 1 && k <= B - 1) { sums[k] += mag[i]; counts[k]++; } } const xs = [], ys = []; for (let k = 1; k < B; k++) if (counts[k]) { const p = sums[k] / counts[k], cen = (edges[k - 1] + edges[k]) / 2; if (p > 0) { xs.push(Math.log(cen)); ys.push(Math.log(p)); } } let slope = 0; if (xs.length > 5) { const mx = xs.reduce((a, b) => a + b) / xs.length, my = ys.reduce((a, b) => a + b) / ys.length; let num = 0, den = 0; for (let i = 0; i < xs.length; i++) { num += (xs[i] - mx) * (ys[i] - my); den += (xs[i] - mx) ** 2; } slope = num / den; } // high-frequency energy share let ring = 0, hf = 0; for (let i = 0; i < mag.length; i++) { if (r[i] > 0.05 && r[i] <= 1) { ring += mag[i]; if (r[i] > 0.6) hf += mag[i]; } } // periodic peaks: spikes far above a local median background let mn = Infinity, mxv = -Infinity; for (const v of mag) { if (v < mn) mn = v; if (v > mxv) mxv = v; } const u8 = new Uint8Array(mag.length); for (let i = 0; i < mag.length; i++) u8[i] = Math.round(((mag[i] - mn) / (mxv - mn || 1)) * 255); const bg = median9(u8, n); const res = []; for (let i = 0; i < mag.length; i++) if (r[i] > 0.15 && r[i] < 0.98) res.push(u8[i] - bg[i]); const rm = res.reduce((a, b) => a + b, 0) / res.length; const rs = Math.sqrt(res.reduce((a, b) => a + (b - rm) ** 2, 0) / res.length); const thresh = rm + 6 * rs; let peaks = 0; for (const v of res) if (v > thresh) peaks++; return { spectral_slope: round(slope), hf_energy_ratio: round(hf / (ring + 1e-9), 4), periodic_peaks: peaks, mag: u8 }; } export function frequencyScore(f) { let s = 0.5 + (Math.min(f.periodic_peaks, 30) / 30) * 0.3; s += clamp((f.spectral_slope + 0.35) * 0.6, -0.1, 0.15); return clamp(s, 0.15, 0.85); } export function frequencySignal(f) { const base = { id: "frequency", name: "Frequency-spectrum fingerprint", category: "forensic" }; const score = frequencyScore(f); let summary; if (f.periodic_peaks >= 12) summary = `Found ${f.periodic_peaks} periodic spectral peaks — a grid pattern often left by AI up-sampling layers.`; else if (score <= 0.4) summary = "The frequency spectrum decays smoothly, like a typical camera photo."; else summary = "The frequency spectrum shows no strong generator fingerprint."; const { mag, ...details } = f; return { ...base, status: "ok", score: round(score), weight: 0.3, summary, details }; } export function spectrumImage(u8, n = FFT_SIZE) { const out = new Uint8ClampedArray(n * n * 4); for (let i = 0; i < u8.length; i++) { const v = u8[i] * 3; out[i * 4] = VIRIDIS[v]; out[i * 4 + 1] = VIRIDIS[v + 1]; out[i * 4 + 2] = VIRIDIS[v + 2]; out[i * 4 + 3] = 255; } return { width: n, height: n, data: out }; } // ------------------------------------------------------------------ neural detector // CommunityForensics ViT-Small (Park & Owens, CVPR 2025): shortest edge → 440, // centre-crop 384, CLIP normalisation, single logit → sigmoid = P(AI-generated). export const MODEL = { id: "buildborderless/CommunityForensics-DeepfakeDet-ViT", url: "https://huggingface.co/buildborderless/CommunityForensics-DeepfakeDet-ViT/resolve/main/onnx/model_int8.onnx", shortEdge: 440, crop: 384, mean: [0.48145466, 0.4578275, 0.40821073], std: [0.26862954, 0.26130258, 0.27577711], }; /** RGBA crop (crop×crop) → Float32Array NCHW, CLIP-normalised. */ export function toModelInput(rgba, size = MODEL.crop) { const t = new Float32Array(3 * size * size), plane = size * size; for (let i = 0; i < plane; i++) for (let c = 0; c < 3; c++) t[c * plane + i] = (rgba[i * 4 + c] / 255 - MODEL.mean[c]) / MODEL.std[c]; return t; } export const sigmoid = (x) => 1 / (1 + Math.exp(-x)); export function learnedSignal(prob, modelName) { const base = { id: "learned", name: "Neural AI-image detector", category: "learned" }; if (prob == null) return { ...base, status: "unavailable", score: null, weight: 0, details: { model: modelName }, summary: "The neural detector couldn't load in this browser. The other checks still apply." }; return { ...base, status: "ok", score: round(prob, 4), weight: 2.0, details: { model: modelName, in_domain: true, probability_ai: round(prob, 4) }, summary: `A Vision Transformer trained on 2.7M images from 4,800+ generators rates this ${Math.round(prob * 100)}% likely AI-generated.` }; } // ------------------------------------------------------------------ fusion export function fuse(signals) { const ev = signals.filter((s) => s.score != null && s.weight > 0); if (!ev.length) return { score: 0.5, verdict: "inconclusive", confidence: "low", headline: "Not enough evidence to judge this file." }; const total = ev.reduce((a, s) => a + s.weight * logit(s.score), 0); const score = clamp(sigmoid(total), 0.001, 0.999); const decisive = ev.some((s) => s.weight >= 2.5); const learned = ev.find((s) => s.category === "learned"); let confidence = "low"; if (decisive) confidence = "high"; else if (learned) confidence = Math.abs(learned.score - 0.5) > 0.35 && learned.details?.in_domain ? "high" : "medium"; let verdict = "inconclusive"; if (confidence !== "low") verdict = score >= 0.65 ? "likely_synthetic" : score <= 0.35 ? "likely_authentic" : "inconclusive"; let headline = { likely_synthetic: "Likely AI-generated or manipulated", likely_authentic: "No signs of AI generation found", inconclusive: "Inconclusive: review the evidence below" }[verdict]; if (confidence === "low") headline = "Limited evidence: only heuristic checks could run"; return { score: round(score, 4), verdict, confidence, headline }; } export const DISCLAIMER = "Automated detection is probabilistic and can be wrong, especially on heavily compressed, " + "resized or novel-generator content. Use this report as evidence to support human review, not as proof.";