diff --git a/README.it.md b/README.it.md index 6da13d5..fd039fe 100644 --- a/README.it.md +++ b/README.it.md @@ -8,16 +8,17 @@ **ghòstati** (dal repository `antagonistrucco`) è una piattaforma sperimentale e uno strumento diagnostico progettato per contrastare gli algoritmi di riconoscimento facciale. Applicando specifici pattern di trucco (ispirati al concetto di CV Dazzle), gli utenti possono esplorare come i modelli di computer vision interpretano i landmark facciali e tentare di offuscare la propria identità digitale in tempo reale. -Il progetto presenta un'architettura modulare basata su plugin, la quale permette a qualsiasi sviluppatore di scrivere script di trucco AR personalizzati ("Ghostyles") e di testarne l'efficacia contro i modelli di riconoscimento direttamente nel browser tramite la webcam. +Il progetto presenta un'architettura modulare basata su plugin, la quale permette a qualsiasi sviluppatore di scrivere script di trucco AR personalizzati ("Ghostyles") e di testarne l'efficacia contro i modelli di riconoscimento direttamente nel browser sia tramite la webcam live sia tramite un file video locale caricato dall'utente. ## Funzionalità Principali - **Live Face Tracking:** Rilevamento dei landmark facciali in tempo reale direttamente nel browser utilizzando `face-api.js`. +- **Flusso a Doppia Sorgente:** Puoi partire dalla webcam live oppure caricare un file video locale. I file locali usano un flusso in due fasi: una fase di selezione per scorrere il video e scegliere il punto di partenza, seguita da una fase overlay per eseguire tracking dei landmark e rendering dei Ghostyle in tempo reale. - **Sistema di Plugin Modulare (Ghostyles):** Carica dinamicamente effetti di trucco AR personalizzati. I plugin possono essere ospitati localmente o tramite URL remoto. Alcuni effetti inclusi: - Graphic Liner, Smokey Eyes, Blush Lift, Lip Tint, Soft Contour, Stage Mask, Splash, etc. - **Modalità Diagnostica ("Scansione Trucco"):** Testa l'efficacia del tuo camouflage AR. Lo strumento valuta l'opacità del trucco, cattura il volto alterato e calcola la probabilità di corrispondenza rispetto ai profili salvati per determinare se il sistema di riconoscimento è stato ingannato. - **Salva e Confronta (Enrolling):** Salva un volto di base iniziale e confrontalo con il feed live della webcam per verificare se l'algoritmo di face matching ti riconosce ancora dopo aver applicato il camuffamento. -- **Privacy-First:** Tutte le elaborazioni vengono eseguite localmente sul computer, senza caricare dati biometrici su server remoti. +- **Privacy-First:** Tutte le elaborazioni vengono eseguite localmente sul computer, senza caricare dati biometrici o file video su server remoti. ## Installazione @@ -35,6 +36,15 @@ Trattandosi di un'applicazione web statica, non è necessario alcun passaggio di python3 -m http.server 8000 ``` 3. Apri un browser moderno e vai all'indirizzo `http://localhost:8000/ghostati-face-api.html`. +4. Scegli `Avvia Webcam` per il flusso live, oppure carica un file con `Carica Video (Locale)`. +5. Se usi un file locale, scorri il video nella fase di selezione e poi premi `AVVIA OVERLAY` per avviare tracking e rendering. + +## Uso Dei File Video Locali + +- L'analisi dei file video locali avviene interamente nel browser e non carica il media su servizi esterni. +- Durante la fase di selezione il video espone i controlli nativi del browser, così puoi scegliere il punto di partenza prima di avviare l'overlay. +- File lunghi, ad alta risoluzione o in 4K possono saturare la memoria del browser. L'interfaccia mostra un indicatore del JS heap per aiutare a capire quando il clip è troppo pesante per la sessione corrente. +- Quando torni alla webcam, la sorgente file viene rilasciata e l'object URL associato viene revocato. ## Sviluppare un Ghostyle (Plugin) diff --git a/README.md b/README.md index 82e47e7..c8028bd 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,17 @@ **ghòstati** (from the repository `antagonistrucco`) is an experimental platform and diagnostic tool designed to counter facial recognition algorithms. By applying specific makeup patterns (inspired by the CV Dazzle concept), users can explore how computer vision models interpret facial landmarks and attempt to anonymize their digital footprint in real time. -The project features a fully modular, plugin-based architecture, allowing any developer to write custom AR makeup scripts ("Ghostyles") and test their efficiacy against recognition models directly in the browser via their webcam. +The project features a fully modular, plugin-based architecture, allowing any developer to write custom AR makeup scripts ("Ghostyles") and test their efficiacy against recognition models directly in the browser either via the live webcam or via a locally uploaded video file. ## Features - **Live Face Tracking:** Real-time facial landmark detection directly in the browser utilizing `face-api.js`. +- **Dual Source Workflow:** You can start from the live webcam or load a local video file. Local files use a two-step flow: a selection phase for scrubbing and choosing the source segment, then an overlay phase for real-time landmark tracking and Ghostyle rendering. - **Modular Plugin System (Ghostyles):** Load custom AR makeup effects dynamically. Plugins can be hosted locally or loaded via a remote URL. Included effects: - Graphic Liner, Smokey Eyes, Blush Lift, Lip Tint, Soft Contour, Stage Mask, Splash, etc. - **Diagnostic Mode ("Scansione Trucco"):** Test the effectiveness of your AR camouflage. The tool evaluates makeup opacity, captures the altered face, and computes matching likelihood against saved profiles to determine if the face recognition system is successfully spoofed. - **Save & Compare:** Save an initial baseline face and compare live webcam feeds to it to check if the face matching algorithm still recognizes you after applying the camouflage. -- **Privacy-First:** All processing is done locally on the client interface without uploading biometric data to remote servers. +- **Privacy-First:** All processing is done locally on the client interface without uploading biometric data or local video files to remote servers. ## Getting Started @@ -35,6 +36,15 @@ Since it's a static web application, there is no build step required. python3 -m http.server 8000 ``` 3. Open a modern browser and navigate to `http://localhost:8000/ghostati-face-api.html`. +4. Choose `Avvia Webcam` for the live camera flow, or upload a local file with `Carica Video (Locale)`. +5. If you load a file, scrub to the point you want to analyze during selection mode, then press `AVVIA OVERLAY` to start tracking and rendering. + +## Working With Local Video Files + +- Local video analysis is performed entirely in-browser and does not upload media anywhere. +- Uploaded files expose native video controls during the selection phase so you can choose the starting point before the overlay loop begins. +- Long, high-resolution, or 4K files can exhaust browser memory. The interface exposes a JS heap readout to help detect when a clip is too heavy for the current browser session. +- When you switch back to the webcam, the file source is released and its object URL is revoked. ## Writing a Ghostyle (Plugin) diff --git a/ghostati-face-api.html b/ghostati-face-api.html index 340ee8a..b8a9258 100644 --- a/ghostati-face-api.html +++ b/ghostati-face-api.html @@ -31,12 +31,12 @@

ghòstati

Questo giocattolo sarà presentato al Festival di NINA, scopri di più (anche internazionale). + href="https://www.nina.watch/festival_eng.html" class="link-accent">(anche internazionale).

- Qui è tutto in ALPHA, se vuoi passa sul repository.

@@ -44,13 +44,56 @@

ghòstati

-
-

+ +
+

+ Sorgente Video +

+
+ Nessuna sorgente +
+
+ + + +
+
+ Attenzione: I browser hanno limiti di risorse stringenti. Video lunghi o 4K possono causare crash di memoria. + +
+ +
+ + +
+
+
+ +
+

Workflow sorgente

+
+ nessuna sorgente + in attesa +
+

Seleziona da dove partire

+

Con la webcam puoi iniziare subito. Con un file locale puoi prima scorrere il video, scegliere il punto utile e poi attivare l'overlay.

+
+
+ Modalità webcam + Feed live immediato, mirror del feed e scansione continua senza fase di scrub. +
+
+ Modalità file locale + Controlli nativi del video in selezione, poi tracking e overlay sul playback quando avvii la fase dedicata. +
+
+
+ +
+

Verifica efficacia

- - +
@@ -77,25 +120,25 @@

Archivio locale

inizializzazione...
-
Caricamento modelli e richiesta webcam…
+
Caricamento modelli e inizializzazione sorgente video…
preview diagnostica
-
In attesa dell’avvio della webcam…
+
In attesa della selezione della sorgente video…

-

Overlay live ancorati ai landmark del volto e visualizzati direttamente sulla webcam.

+

Overlay live ancorati ai landmark del volto e visualizzati direttamente sul feed video.

-
- @@ -103,18 +146,16 @@

Archivio locale

-
- + - - 📖 Sviluppa in + 📖 Sviluppa in Ghostyle

- Un click attiva la guida sulla webcam; un secondo click sullo stesso pulsante la spegne e torna il feed + Un click attiva la guida sul feed selezionato; un secondo click sullo stesso pulsante la spegne e torna il feed pulito.

@@ -128,7 +169,7 @@

Modalità attiva

- + \ No newline at end of file diff --git a/scripts/ghostati-face-api.js b/scripts/ghostati-face-api.js deleted file mode 100644 index 6d5ed16..0000000 --- a/scripts/ghostati-face-api.js +++ /dev/null @@ -1,840 +0,0 @@ -const MODEL_URLS = { - tiny: 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights', - landmarks: 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js-models@master/face_landmark_68', - recognition: 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js-models@master/face_recognition', - ageGender: 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js-models@master/age_gender_model' -}; - -const STORAGE_KEY = 'local-face-lab-db-v1'; -const MATCH_THRESHOLD = 0.58; -const DETECTOR_OPTIONS = new faceapi.TinyFaceDetectorOptions({ - inputSize: 416, - scoreThreshold: 0.5 -}); - -const els = { - video: document.getElementById('video'), - overlay: document.getElementById('overlay'), - viewer: document.getElementById('viewer'), - placeholder: document.getElementById('placeholder'), - previewImage: document.getElementById('previewImage'), - logBox: document.getElementById('logBox'), - statusDot: document.getElementById('statusDot'), - statusText: document.getElementById('statusText'), - dbCount: document.getElementById('dbCount'), - nextId: document.getElementById('nextId'), - thresholdLabel: document.getElementById('thresholdLabel'), - effectName: document.getElementById('effectName'), - effectTracking: document.getElementById('effectTracking'), - scanBtn: document.getElementById('scanBtn'), - copyMakeupBtn: document.getElementById('copyMakeupBtn'), - saveBtn: document.getElementById('saveBtn'), - findBtn: document.getElementById('findBtn'), - clearDbBtn: document.getElementById('clearDbBtn'), - clearOverlayBtn: document.getElementById('clearOverlayBtn'), - ghostylesContainer: document.getElementById('ghostylesContainer'), - remoteGhostyleUrl: document.getElementById('remoteGhostyleUrl'), - loadRemoteGhostyleBtn: document.getElementById('loadRemoteGhostyleBtn'), - mirrorToggle: document.getElementById('mirrorToggle'), - fpsSelect: document.getElementById('fpsSelect') -}; - -let overlayFadeTimeout = null; -function triggerOverlayFadeout() { - els.overlay.style.transition = 'none'; - els.overlay.style.opacity = '1'; - void els.overlay.offsetHeight; // force reflow - els.overlay.style.transition = 'opacity 2s ease-in-out'; - - if (overlayFadeTimeout) clearTimeout(overlayFadeTimeout); - overlayFadeTimeout = setTimeout(() => { - els.overlay.style.opacity = '0'; - }, 5000); -} - -let isMirrored = false; -// Mirror toggle logic -els.mirrorToggle.addEventListener('click', () => { - isMirrored = !isMirrored; - els.video.style.transform = isMirrored ? 'scaleX(-1)' : 'scaleX(1)'; - els.overlay.style.transform = isMirrored ? 'scaleX(-1)' : 'scaleX(1)'; - els.mirrorToggle.classList.toggle('mirrored', isMirrored); - els.mirrorToggle.textContent = isMirrored ? 'Webcam speculare: ON' : 'Mirror webcam'; -}); -// Initialize mirror state on load -els.video.style.transform = 'scaleX(1)'; -els.overlay.style.transform = 'scaleX(1)'; - -let db = loadDb(); -let activeEffect = null; -let effectLoopHandle = null; -let effectInferenceInFlight = false; -let lastEffectRun = 0; -let isSystemBusy = false; -let lastKnownEffectResult = null; -let lastCompositedCanvas = null; - -function loadDb() { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return { nextId: 0, faces: [] }; - const parsed = JSON.parse(raw); - if (!parsed || !Array.isArray(parsed.faces) || typeof parsed.nextId !== 'number') { - return { nextId: 0, faces: [] }; - } - return parsed; - } catch { - return { nextId: 0, faces: [] }; - } -} - -function persistDb() { - localStorage.setItem(STORAGE_KEY, JSON.stringify(db)); - renderDbStats(); -} - -function renderDbStats() { - els.dbCount.textContent = String(db.faces.length); - els.nextId.textContent = String(db.nextId); - els.thresholdLabel.textContent = MATCH_THRESHOLD.toFixed(2); -} - -function updateEffectStats() { - const style = loadedGhostyles.get(activeEffect); - els.effectName.textContent = style ? style.name : 'nessuno'; - els.effectTracking.textContent = activeEffect ? 'on' : 'off'; -} - -function setLog(message, sourcePlugin = null) { - const line = document.createElement('div'); - line.className = 'log-line'; - - if (sourcePlugin) { - const span = document.createElement('span'); - span.style.color = 'var(--accent-2)'; - span.style.fontWeight = '800'; - span.style.marginRight = '8px'; - span.textContent = `[${sourcePlugin.toUpperCase()}]`; - line.appendChild(span); - } - const textSpan = document.createElement('span'); - textSpan.textContent = message; - line.appendChild(textSpan); - - els.logBox.insertBefore(line, els.logBox.firstChild); - - while (els.logBox.children.length > 5) { - els.logBox.removeChild(els.logBox.lastChild); - } -} - -function setStatus(kind, text) { - els.statusDot.className = 'status-dot'; - if (kind === 'live') els.statusDot.classList.add('live'); - if (kind === 'error') els.statusDot.classList.add('error'); - els.statusText.textContent = text; -} - -function setBusy(isBusy) { - isSystemBusy = isBusy; - [els.scanBtn, els.copyMakeupBtn, els.saveBtn, els.findBtn, els.clearDbBtn, els.clearOverlayBtn, els.loadRemoteGhostyleBtn].forEach(btn => { - if (btn === els.copyMakeupBtn && !lastCompositedCanvas) btn.disabled = true; - else btn.disabled = isBusy; - }); - const previewBtns = els.ghostylesContainer.querySelectorAll('.preview-btn'); - previewBtns.forEach(btn => btn.disabled = isBusy); -} - -function resizeCanvas() { - const rect = els.viewer.getBoundingClientRect(); - els.overlay.width = Math.max(1, Math.floor(rect.width)); - els.overlay.height = Math.max(1, Math.floor(rect.height)); -} - -function clearOverlay() { - const ctx = els.overlay.getContext('2d'); - ctx.clearRect(0, 0, els.overlay.width, els.overlay.height); - els.overlay.style.transition = 'none'; - els.overlay.style.opacity = '1'; - if (overlayFadeTimeout) clearTimeout(overlayFadeTimeout); -} - -function distance(a, b) { - if (!a || !b || a.length !== b.length) return Number.POSITIVE_INFINITY; - let sum = 0; - for (let i = 0; i < a.length; i += 1) { - const d = a[i] - b[i]; - sum += d * d; - } - return Math.sqrt(sum); -} - -function avgPoint(points) { - const total = points.reduce((acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 }); - return { x: total.x / points.length, y: total.y / points.length }; -} - -function lerp(a, b, t) { - return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; -} - -function scaleFrom(center, point, scale) { - return { x: center.x + (point.x - center.x) * scale, y: center.y + (point.y - center.y) * scale }; -} - -function point(x, y) { - return { x, y }; -} - -function drawClosedPath(ctx, points, fillStyle = null, strokeStyle = null, lineWidth = 2) { - if (!points.length) return; - ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y); - ctx.closePath(); - if (fillStyle) { - ctx.fillStyle = fillStyle; - ctx.fill(); - } - if (strokeStyle) { - ctx.lineWidth = lineWidth; - ctx.strokeStyle = strokeStyle; - ctx.stroke(); - } -} - -function drawOpenPath(ctx, points, strokeStyle, lineWidth = 2, dashed = false) { - if (!points.length) return; - ctx.save(); - ctx.beginPath(); - ctx.moveTo(points[0].x, points[0].y); - for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y); - ctx.lineWidth = lineWidth; - ctx.strokeStyle = strokeStyle; - if (dashed) ctx.setLineDash([10, 8]); - ctx.stroke(); - ctx.restore(); -} - -function drawLabel(ctx, text, x, y) { - ctx.save(); - ctx.font = '700 14px Inter, system-ui, sans-serif'; - const padX = 10; - const padY = 7; - const width = ctx.measureText(text).width + padX * 2; - const height = 30; - ctx.fillStyle = 'rgba(15, 17, 21, 0.78)'; - ctx.strokeStyle = 'rgba(255,255,255,0.10)'; - ctx.lineWidth = 1; - roundRect(ctx, x, y - height, width, height, 12); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = 'rgba(238, 242, 255, 0.96)'; - ctx.fillText(text, x + padX, y - 10); - ctx.restore(); -} - -function roundRect(ctx, x, y, w, h, r) { - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.arcTo(x + w, y, x + w, y + h, r); - ctx.arcTo(x + w, y + h, x, y + h, r); - ctx.arcTo(x, y + h, x, y, r); - ctx.arcTo(x, y, x + w, y, r); - ctx.closePath(); -} - -function expandEyePolygon(eye, eyebrow, scale = 1.22, eyebrowLift = 0.72) { - const center = avgPoint(eye); - const topBrow = eyebrow.map((b, idx) => { - const eyeRef = eye[Math.min(idx + 1, eye.length - 1)] || eye[eye.length - 1]; - return lerp(eyeRef, b, eyebrowLift); - }); - const expandedEye = eye.map(pt => scaleFrom(center, pt, scale)); - return [...topBrow, expandedEye[3], expandedEye[4], expandedEye[5], expandedEye[0]]; -} - -function drawEyeWing(ctx, eye, eyebrow, label, tone) { - const eyeShape = expandEyePolygon(eye, eyebrow, tone.scale, tone.brow); - drawClosedPath(ctx, eyeShape, tone.fill, tone.stroke, 2.2); - const outerCorner = tone.side === 'left' - ? eye.reduce((best, p) => (p.x < best.x ? p : best), eye[0]) - : eye.reduce((best, p) => (p.x > best.x ? p : best), eye[0]); - const tailTop = point(outerCorner.x + tone.tailX, outerCorner.y - tone.tailY); - const tailLow = point(outerCorner.x + tone.tailX * 0.7, outerCorner.y + tone.tailY * 0.12); - drawClosedPath(ctx, [outerCorner, tailTop, tailLow], tone.fill, tone.stroke, 2.2); - const sorted = [...eye].sort((a, b) => a.x - b.x); - const linePts = tone.side === 'left' ? [sorted[2], sorted[1], sorted[0], tailTop] : [sorted[sorted.length - 3], sorted[sorted.length - 2], sorted[sorted.length - 1], tailTop]; - drawOpenPath(ctx, linePts, tone.line, 3.2); - drawLabel(ctx, label, tailTop.x + (tone.side === 'left' ? -52 : 10), tailTop.y - 10); -} - -function drawCheekSweep(ctx, anchor, noseSide, mouthCorner, jawPoint, label, fill, stroke) { - const upper = lerp(anchor, noseSide, 0.42); - const lower = lerp(mouthCorner, jawPoint, 0.36); - const side = lerp(anchor, jawPoint, 0.54); - const cheek = [ - upper, - lerp(anchor, side, 0.45), - side, - lower, - lerp(lower, mouthCorner, 0.55), - lerp(mouthCorner, noseSide, 0.42) - ]; - drawClosedPath(ctx, cheek, fill, stroke, 1.8); - drawLabel(ctx, label, side.x - 20, side.y - 12); -} - -function drawContourBand(ctx, pts, label) { - drawOpenPath(ctx, pts, 'rgba(193, 154, 107, 0.95)', 7, true); - drawOpenPath(ctx, pts, 'rgba(90, 54, 33, 0.22)', 16); - const mid = pts[Math.floor(pts.length / 2)]; - drawLabel(ctx, label, mid.x + 10, mid.y - 6); -} - -window.Ghostati = { - log: (message, sourcePlugin) => setLog(message, sourcePlugin), - distance, - avgPoint, - lerp, - scaleFrom, - point, - drawClosedPath, - drawOpenPath, - drawLabel, - roundRect, - expandEyePolygon, - drawEyeWing, - drawCheekSweep, - drawContourBand -}; - -const loadedGhostyles = new Map(); - -async function loadGhostyle(url, expectedName = null) { - const id = url.split('/').pop().replace('.js', ''); - try { - setLog(`Caricamento ghostyle da ${url}...`); - - const res = await fetch(url); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const text = await res.text(); - const matchName = text.match(/@name\s+(.+)/); - const name = matchName ? matchName[1].trim() : (expectedName || id); - - const module = await import(url); - loadedGhostyles.set(id, { id, name, module, url }); - - const btn = document.createElement('button'); - btn.className = 'preview-btn'; - btn.textContent = name; - btn.dataset.effect = id; - btn.onclick = () => toggleEffect(id, btn); - - els.ghostylesContainer.appendChild(btn); - setLog(`Ghostyle '${name}' pronto all'uso.`); - - if (module.onInit) { - module.onInit(); - } - } catch (err) { - console.error(err); - const btn = document.createElement('button'); - btn.className = 'preview-btn'; - btn.textContent = expectedName || id; - btn.disabled = true; - btn.style.color = 'var(--danger)'; - btn.style.borderColor = 'rgba(255, 122, 122, 0.4)'; - btn.title = `Errore di caricamento: ${err.message}`; - els.ghostylesContainer.appendChild(btn); - setLog(`Impossibile caricare Ghostyle ${expectedName || id}: ${err.message}`, 'Sistema (Error)'); - } -} - -function drawDetectionScaffold(ctx, resized) { - const box = resized.detection.box; - const landmarks = resized.landmarks; - const leftEye = landmarks.getLeftEye(); - const rightEye = landmarks.getRightEye(); - const nose = landmarks.getNose(); - const jaw = landmarks.getJawOutline(); - const mouth = landmarks.getMouth(); - - ctx.save(); - ctx.lineWidth = 2.2; - ctx.strokeStyle = 'rgba(122, 162, 255, 0.95)'; - ctx.strokeRect(box.x, box.y, box.width, box.height); - - const leftCenter = avgPoint(leftEye); - const rightCenter = avgPoint(rightEye); - ctx.beginPath(); - ctx.moveTo(leftCenter.x, leftCenter.y); - ctx.lineTo(rightCenter.x, rightCenter.y); - ctx.stroke(); - - ctx.strokeStyle = 'rgba(255, 122, 122, 0.85)'; - drawClosedPath(ctx, leftEye, null, 'rgba(255, 122, 122, 0.85)', 2); - drawClosedPath(ctx, rightEye, null, 'rgba(255, 122, 122, 0.85)', 2); - - ctx.strokeStyle = 'rgba(159, 122, 234, 0.88)'; - drawOpenPath(ctx, jaw, 'rgba(159, 122, 234, 0.88)', 2); - ctx.strokeStyle = 'rgba(61, 220, 151, 0.88)'; - drawOpenPath(ctx, nose, 'rgba(61, 220, 151, 0.88)', 2); - ctx.strokeStyle = 'rgba(255, 204, 102, 0.88)'; - drawClosedPath(ctx, mouth, null, 'rgba(255, 204, 102, 0.88)', 2); - - ctx.fillStyle = 'rgba(255, 255, 255, 0.92)'; - [leftCenter, rightCenter, avgPoint(nose.slice(3)), avgPoint(mouth.slice(0, 7))].forEach(pt => { - ctx.beginPath(); - ctx.arc(pt.x, pt.y, 3.4, 0, Math.PI * 2); - ctx.fill(); - }); - - const lines = ['volto rilevato']; - if (typeof resized.age === 'number') lines.push(`eta stimata: ${Math.round(resized.age)}`); - if (resized.gender) lines.push(`genere stimato: ${resized.gender}`); - new faceapi.draw.DrawTextField(lines, { x: box.x, y: Math.max(16, box.y - 8) }).draw(els.overlay); - ctx.restore(); -} - -function drawEffectOverlay(result, includeDetectionScaffold = false) { - resizeCanvas(); - const ctx = els.overlay.getContext('2d'); - ctx.clearRect(0, 0, els.overlay.width, els.overlay.height); - const resized = faceapi.resizeResults(result, { width: els.overlay.width, height: els.overlay.height }); - if (includeDetectionScaffold) drawDetectionScaffold(ctx, resized); - if (activeEffect) { - const style = loadedGhostyles.get(activeEffect); - if (style && style.module.onDraw) { - ctx.save(); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - style.module.onDraw(ctx, resized.landmarks, resized.detection.box); - ctx.restore(); - } - } - lastKnownEffectResult = result; -} - -async function detectCurrentFace(drawOverlay = true) { - clearOverlay(); - const result = await faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS) - .withFaceLandmarks() - .withAgeAndGender() - .withFaceDescriptor(); - - if (!result) { - lastKnownEffectResult = null; - setLog('Nessun volto rilevato nella webcam.'); - return null; - } - - if (drawOverlay) drawResult(result); - return result; -} - -function drawResult(result) { - resizeCanvas(); - const ctx = els.overlay.getContext('2d'); - ctx.clearRect(0, 0, els.overlay.width, els.overlay.height); - const resized = faceapi.resizeResults(result, { width: els.overlay.width, height: els.overlay.height }); - drawDetectionScaffold(ctx, resized); - if (activeEffect) { - const style = loadedGhostyles.get(activeEffect); - if (style && style.module.onDraw) { - ctx.save(); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - style.module.onDraw(ctx, resized.landmarks, resized.detection.box); - ctx.restore(); - } - } - lastKnownEffectResult = result; -} - -async function runEffectPass() { - if (!activeEffect || isSystemBusy || effectInferenceInFlight || els.video.readyState < 2) return; - effectInferenceInFlight = true; - try { - const result = await faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS).withFaceLandmarks(); - if (!result) { - lastKnownEffectResult = null; - clearOverlay(); - return; - } - drawEffectOverlay(result, false); - } catch (err) { - console.error(err); - } finally { - effectInferenceInFlight = false; - } -} - -function effectLoop(ts = 0) { - if (!activeEffect) { - effectLoopHandle = null; - return; - } - const currentDelay = parseInt(els.fpsSelect.value, 10) || 120; - if (ts - lastEffectRun > currentDelay) { - lastEffectRun = ts; - runEffectPass(); - } - effectLoopHandle = requestAnimationFrame(effectLoop); -} - -function startEffectLoop() { - if (effectLoopHandle) cancelAnimationFrame(effectLoopHandle); - effectLoopHandle = requestAnimationFrame(effectLoop); -} - -function stopEffectLoop() { - if (effectLoopHandle) cancelAnimationFrame(effectLoopHandle); - effectLoopHandle = null; - effectInferenceInFlight = false; -} - -function deactivateEffect({ silent = false } = {}) { - if (activeEffect) { - const style = loadedGhostyles.get(activeEffect); - if (style && style.module.onClear) { - style.module.onClear(els.overlay.getContext('2d')); - } - } - activeEffect = null; - stopEffectLoop(); - const previewBtns = els.ghostylesContainer.querySelectorAll('.preview-btn'); - previewBtns.forEach(btn => btn.classList.remove('active')); - els.scanBtn.textContent = 'Scansiona faccia'; - els.scanBtn.style.background = ''; - els.scanBtn.style.borderColor = ''; - els.scanBtn.style.color = ''; - - updateEffectStats(); - lastKnownEffectResult = null; - lastCompositedCanvas = null; - els.copyMakeupBtn.disabled = true; - clearOverlay(); - if (!silent) setLog('Guida makeup disattivata. Webcam ripristinata senza overlay.'); -} - -function toggleEffect(effect, button) { - if (activeEffect === effect) { - deactivateEffect(); - return; - } - - if (activeEffect) { - const styleId = activeEffect; - deactivateEffect({ silent: true }); - // wait for complete removal then continue - } - - activeEffect = effect; - const previewBtns = els.ghostylesContainer.querySelectorAll('.preview-btn'); - previewBtns.forEach(btn => btn.classList.toggle('active', btn === button)); - els.previewImage.style.display = 'none'; - els.previewImage.removeAttribute('src'); - updateEffectStats(); - - const style = loadedGhostyles.get(effect); - setLog(`Guida makeup attiva: ${style ? style.name : effect}. Cerca un volto nella webcam per vedere dove applicarlo.`); - - els.scanBtn.textContent = 'Scansiona trucco'; - els.scanBtn.style.background = 'linear-gradient(180deg, rgba(159, 122, 234, 0.35), rgba(159, 122, 234, 0.15))'; - els.scanBtn.style.borderColor = 'rgba(159, 122, 234, 0.5)'; - els.scanBtn.style.color = '#fff'; - - if (overlayFadeTimeout) clearTimeout(overlayFadeTimeout); - els.overlay.style.transition = 'none'; - els.overlay.style.opacity = '1'; - - startEffectLoop(); -} - -async function scanFace() { - const result = await detectCurrentFace(true); - if (!result) return; - triggerOverlayFadeout(); - const age = Math.round(result.age); - const gender = result.gender || 'n/d'; - const confidence = typeof result.genderProbability === 'number' ? ` (${Math.round(result.genderProbability * 100)}%)` : ''; - setLog(`Volto trovato. Età stimata: ${age}. Genere stimato: ${gender}${confidence}. Overlay biometrico aggiornato.`); -} - -async function saveFace() { - const result = await detectCurrentFace(true); - if (!result) return; - triggerOverlayFadeout(); - const id = db.nextId; - db.nextId += 1; - db.faces.push({ - id, - descriptor: Array.from(result.descriptor), - age: Math.round(result.age), - gender: result.gender || null, - savedAt: new Date().toISOString() - }); - persistDb(); - setLog(`Impronta biometrica salvata con ID ${id}. Archivio locale aggiornato.`); -} - -async function findFace() { - if (db.faces.length === 0) { - setLog('Archivio locale vuoto. Salva almeno un volto prima della ricerca.'); - clearOverlay(); - return; - } - - const result = await detectCurrentFace(true); - if (!result) return; - triggerOverlayFadeout(); - - const matches = db.faces - .map(entry => ({ id: entry.id, distance: distance(result.descriptor, entry.descriptor) })) - .filter(entry => entry.distance <= MATCH_THRESHOLD) - .sort((a, b) => a.distance - b.distance); - - if (!matches.length) { - setLog(`Nessuna corrispondenza trovata sotto soglia ${MATCH_THRESHOLD.toFixed(2)}.`); - return; - } - - const summary = matches.map(m => `${m.id} (${m.distance.toFixed(3)})`).join(', '); - setLog(`Corrispondenze trovate: ${summary}.`); -} - -async function startCamera() { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { - width: { ideal: 1920 }, - height: { ideal: 1080 }, - facingMode: 'user' - }, - audio: false - }); - els.video.srcObject = stream; - await new Promise(resolve => { - els.video.onloadedmetadata = () => resolve(); - }); - await els.video.play(); - els.placeholder.style.display = 'none'; - setStatus('live', 'webcam attiva'); - setLog('Webcam attiva. Premi “Scansiona faccia” o attiva una guida makeup AR dalla colonna destra.'); - resizeCanvas(); -} - -async function loadModels() { - setStatus('init', 'caricamento modelli'); - setLog('Caricamento modelli face-api.js in corso…'); - await Promise.all([ - faceapi.nets.tinyFaceDetector.loadFromUri('https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js-models@master/tiny_face_detector'), - faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URLS.landmarks), - faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URLS.recognition), - faceapi.nets.ageGenderNet.loadFromUri(MODEL_URLS.ageGender) - ]); -} - -function clearDb() { - db = { nextId: 0, faces: [] }; - persistDb(); - setLog('Archivio locale cancellato. Il contatore ID riparte da 0.'); -} - -function handleError(err, fallbackMessage) { - console.error(err); - setStatus('error', 'errore'); - els.placeholder.style.display = 'grid'; - const detail = err && err.message ? ` (${err.message})` : ''; - setLog(fallbackMessage + detail); -} - -async function testMakeupEfficacy() { - const result = await detectCurrentFace(false); - if (!result) { - setLog('Nessun volto di base trovato. Avvicinati alla webcam.'); - return; - } - - const canvas = document.createElement('canvas'); - canvas.width = els.overlay.width; - canvas.height = els.overlay.height; - const ctx = canvas.getContext('2d'); - - ctx.drawImage(els.video, 0, 0, canvas.width, canvas.height); - - const style = loadedGhostyles.get(activeEffect); - if (style && style.module.onDraw) { - ctx.save(); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - const resized = faceapi.resizeResults(result, { width: canvas.width, height: canvas.height }); - style.module.onDraw(ctx, resized.landmarks, resized.detection.box); - ctx.restore(); - } - - lastCompositedCanvas = canvas; - els.copyMakeupBtn.disabled = false; - - setLog('Analisi in corso... sottopongo il compositing a face-api'); - - const obfuscatedResult = await faceapi.detectSingleFace(canvas, DETECTOR_OPTIONS) - .withFaceLandmarks() - .withFaceDescriptor(); - - if (!obfuscatedResult) { - setLog('Risultato: ECCELLENTE. Il trucco ha frammentato il volto al punto da distruggere l\'algoritmo (nessun volto rilevato).'); - return; - } - - const dist = distance(result.descriptor, obfuscatedResult.descriptor); - if (dist > MATCH_THRESHOLD) { - setLog(`Risultato: BUONO (Spoofed). Volto individuato, ma l'identità biometrica è irriconoscibile (distanza: ${dist.toFixed(3)}).`); - } else { - setLog(`Risultato: INSUFFICIENTE. Il sistema ti riconosce ancora perfettamente (distanza: ${dist.toFixed(3)} <= ${MATCH_THRESHOLD.toFixed(2)}). Aggiungi geometrie.`); - } -} - -async function init() { - renderDbStats(); - updateEffectStats(); - resizeCanvas(); - window.addEventListener('resize', resizeCanvas); - - els.copyMakeupBtn.addEventListener('click', async () => { - if (!lastCompositedCanvas) return; - try { - const exportCanvas = document.createElement('canvas'); - const headerHeight = 44; - const footerHeight = 50; - exportCanvas.width = lastCompositedCanvas.width; - exportCanvas.height = lastCompositedCanvas.height + headerHeight + footerHeight; - const ctx = exportCanvas.getContext('2d'); - - ctx.fillStyle = '#0f1115'; - ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); - - ctx.drawImage(lastCompositedCanvas, 0, headerHeight); - - ctx.fillStyle = '#eef2ff'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - const style = loadedGhostyles.get(activeEffect); - const pluginName = style ? style.name : 'Unknown Plugin'; - ctx.font = 'bold 14px Inter, sans-serif'; - ctx.fillText(`github.com/vecna/antagonistrucco | Modulo attivo: ${pluginName}`, exportCanvas.width / 2, headerHeight / 2); - - const logText = els.logBox.firstChild ? els.logBox.firstChild.textContent : ''; - ctx.font = '14px Inter, sans-serif'; - ctx.fillStyle = '#3ddc97'; // default verde o bianco, usiamo un bianco leggero - if (logText.includes('ECCELLENTE') || logText.includes('BUONO')) ctx.fillStyle = '#3ddc97'; - else if (logText.includes('INSUFFICIENTE')) ctx.fillStyle = '#ff7a7a'; - else ctx.fillStyle = '#eef2ff'; - - ctx.fillText(logText, exportCanvas.width / 2, exportCanvas.height - footerHeight / 2); - - exportCanvas.toBlob(blob => { - navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => { - setLog('Immagine con referto diagnostico copiata negli appunti!'); - }); - }); - } catch (err) { - console.error(err); - setLog('Errore durante la copia. Forse manca il permesso nel browser?'); - } - }); - - els.scanBtn.addEventListener('click', async () => { - setBusy(true); - try { - if (activeEffect) { - await testMakeupEfficacy(); - // Il trucco rimane bloccato sullo schermo, niente fadeout o clear - } else { - await scanFace(); - } - } - catch (err) { handleError(err, 'Errore durante la scansione o l\'analisi avversaria.'); } - finally { - setBusy(false); - if (activeEffect) startEffectLoop(); - } - }); - - els.saveBtn.addEventListener('click', async () => { - setBusy(true); - try { await saveFace(); } - catch (err) { handleError(err, 'Errore durante il salvataggio del volto.'); } - finally { - setBusy(false); - if (activeEffect) startEffectLoop(); - } - }); - - els.findBtn.addEventListener('click', async () => { - setBusy(true); - try { await findFace(); } - catch (err) { handleError(err, 'Errore durante la ricerca del volto.'); } - finally { - setBusy(false); - if (activeEffect) startEffectLoop(); - } - }); - - els.clearDbBtn.addEventListener('click', () => { - if (els.clearDbBtn.textContent === 'Conferma azzeramento?') { - clearDb(); - els.clearDbBtn.textContent = 'Azzera archivio locale'; - } else { - els.clearDbBtn.textContent = 'Conferma azzeramento?'; - setTimeout(() => { - if (els.clearDbBtn.textContent === 'Conferma azzeramento?') { - els.clearDbBtn.textContent = 'Azzera archivio locale'; - } - }, 4000); - } - }); - els.clearOverlayBtn.addEventListener('click', () => { - if (activeEffect) deactivateEffect({ silent: true }); - clearOverlay(); - setLog('Overlay pulito.'); - }); - - els.loadRemoteGhostyleBtn.addEventListener('click', async () => { - const url = els.remoteGhostyleUrl.value.trim(); - if (url) { - els.loadRemoteGhostyleBtn.disabled = true; - await loadGhostyle(url); - els.remoteGhostyleUrl.value = ''; - els.loadRemoteGhostyleBtn.disabled = false; - } - }); - - setBusy(true); - try { - await loadModels(); - await startCamera(); - - try { - const ghostylistRes = await fetch('ghostylist.json'); - if (ghostylistRes.ok) { - const list = await ghostylistRes.json(); - for (const item of list) { - await loadGhostyle(item.url, item.name); - } - } else { - setLog('File ghostylist.json non trovato, caricamento plugin saltato.', 'Sistema'); - } - } catch (err) { - setLog('Errore durante la lettura di ghostylist.json: ' + err.message, 'Sistema'); - } - } catch (err) { - handleError(err, 'Impossibile inizializzare webcam o modelli. Apri la pagina da localhost e verifica i permessi camera.'); - return; - } finally { - setBusy(false); - } -} - -init(); \ No newline at end of file diff --git a/scripts/overlay.js b/scripts/overlay.js new file mode 100644 index 0000000..0cc635d --- /dev/null +++ b/scripts/overlay.js @@ -0,0 +1,1013 @@ +import { WebcamSource, createMirrorController } from './webcam.js'; +import { FileSource, createPhaseController, formatBytes, startMemoryMonitor } from './video.js'; + +const faceapi = window.faceapi; + +if (!faceapi) { + throw new Error('face-api.js non disponibile.'); +} + +const MODEL_URLS = { + tiny: 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights', + landmarks: 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js-models@master/face_landmark_68', + recognition: 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js-models@master/face_recognition', + ageGender: 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js-models@master/age_gender_model' +}; + +const STORAGE_KEY = 'local-face-lab-db-v1'; +const MATCH_THRESHOLD = 0.58; +const DETECTOR_OPTIONS = new faceapi.TinyFaceDetectorOptions({ + inputSize: 416, + scoreThreshold: 0.5 +}); + +const els = { + video: document.getElementById('video'), + overlay: document.getElementById('overlay'), + viewer: document.getElementById('viewer'), + placeholder: document.getElementById('placeholder'), + previewImage: document.getElementById('previewImage'), + logBox: document.getElementById('logBox'), + statusDot: document.getElementById('statusDot'), + statusText: document.getElementById('statusText'), + dbCount: document.getElementById('dbCount'), + nextId: document.getElementById('nextId'), + thresholdLabel: document.getElementById('thresholdLabel'), + effectName: document.getElementById('effectName'), + effectTracking: document.getElementById('effectTracking'), + scanBtn: document.getElementById('scanBtn'), + copyMakeupBtn: document.getElementById('copyMakeupBtn'), + saveBtn: document.getElementById('saveBtn'), + findBtn: document.getElementById('findBtn'), + clearDbBtn: document.getElementById('clearDbBtn'), + clearOverlayBtn: document.getElementById('clearOverlayBtn'), + ghostylesContainer: document.getElementById('ghostylesContainer'), + remoteGhostyleUrl: document.getElementById('remoteGhostyleUrl'), + loadRemoteGhostyleBtn: document.getElementById('loadRemoteGhostyleBtn'), + mirrorToggle: document.getElementById('mirrorToggle'), + fpsSelect: document.getElementById('fpsSelect'), + videoSourceIndicator: document.getElementById('videoSourceIndicator'), + srcWebcamBtn: document.getElementById('srcWebcamBtn'), + srcFileInput: document.getElementById('srcFileInput'), + fileInfoBox: document.getElementById('fileInfoBox'), + fileSizeLabel: document.getElementById('fileSizeLabel'), + memoryLabel: document.getElementById('memoryLabel'), + phaseTransitionBox: document.getElementById('phaseTransitionBox'), + phaseStartBtn: document.getElementById('phaseStartBtn'), + phaseStopBtn: document.getElementById('phaseStopBtn'), + workflowSourceBadge: document.getElementById('workflowSourceBadge'), + workflowPhaseBadge: document.getElementById('workflowPhaseBadge'), + workflowHintTitle: document.getElementById('workflowHintTitle'), + workflowHintText: document.getElementById('workflowHintText'), + workflowWebcamCard: document.getElementById('workflowWebcamCard'), + workflowFileCard: document.getElementById('workflowFileCard') +}; + +let overlayFadeTimeout = null; +let db = loadDb(); +let activeEffect = null; +let effectLoopHandle = null; +let effectLoopUsesVideoFrames = false; +let effectInferenceInFlight = false; +let lastEffectRun = 0; +let isSystemBusy = false; +let lastKnownEffectResult = null; +let lastCompositedCanvas = null; +let phaseController = null; + +const stopMemoryMonitor = startMemoryMonitor(els.memoryLabel); + +createMirrorController({ + buttonEl: els.mirrorToggle, + videoEl: els.video, + overlayEl: els.overlay +}); + +function triggerOverlayFadeout() { + els.overlay.style.transition = 'none'; + els.overlay.style.opacity = '1'; + void els.overlay.offsetHeight; + els.overlay.style.transition = 'opacity 2s ease-in-out'; + + if (overlayFadeTimeout) clearTimeout(overlayFadeTimeout); + overlayFadeTimeout = setTimeout(() => { + els.overlay.style.opacity = '0'; + }, 5000); +} + +function loadDb() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { nextId: 0, faces: [] }; + + const parsed = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.faces) || typeof parsed.nextId !== 'number') { + return { nextId: 0, faces: [] }; + } + + return parsed; + } catch { + return { nextId: 0, faces: [] }; + } +} + +function persistDb() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(db)); + renderDbStats(); +} + +function renderDbStats() { + els.dbCount.textContent = String(db.faces.length); + els.nextId.textContent = String(db.nextId); + els.thresholdLabel.textContent = MATCH_THRESHOLD.toFixed(2); +} + +function updateEffectStats() { + const style = loadedGhostyles.get(activeEffect); + els.effectName.textContent = style ? style.name : 'nessuno'; + els.effectTracking.textContent = activeEffect ? 'on' : 'off'; +} + +function updateWorkflowGuide(source = null, phase = null) { + const sourceKind = source && source.kind ? source.kind : null; + const phaseKey = phase ? phase.toLowerCase() : 'idle'; + + els.workflowSourceBadge.textContent = sourceKind === 'webcam' + ? 'webcam' + : sourceKind === 'file' + ? 'file locale' + : 'nessuna sorgente'; + + els.workflowPhaseBadge.textContent = phaseKey === 'selection' + ? 'selezione' + : phaseKey === 'overlay' + ? 'overlay' + : 'in attesa'; + + els.workflowPhaseBadge.classList.toggle('is-selection', phaseKey === 'selection'); + els.workflowPhaseBadge.classList.toggle('is-overlay', phaseKey === 'overlay'); + + els.workflowWebcamCard.classList.toggle('is-active', sourceKind === 'webcam'); + els.workflowFileCard.classList.toggle('is-active', sourceKind === 'file'); + els.workflowWebcamCard.classList.toggle('is-dimmed', Boolean(sourceKind) && sourceKind !== 'webcam'); + els.workflowFileCard.classList.toggle('is-dimmed', Boolean(sourceKind) && sourceKind !== 'file'); + + if (!sourceKind) { + els.workflowHintTitle.textContent = 'Seleziona da dove partire'; + els.workflowHintText.textContent = 'Con la webcam puoi iniziare subito. Con un file locale puoi prima scorrere il video, scegliere il punto utile e poi attivare l\'overlay.'; + return; + } + + if (sourceKind === 'webcam' && phaseKey === 'selection') { + els.workflowHintTitle.textContent = 'Webcam pronta per il feed live'; + els.workflowHintText.textContent = 'Puoi usare subito mirror, scansione, salvataggio e ricerca. Se attivi un Ghostyle, l\'overlay seguirà il volto in diretta.'; + return; + } + + if (sourceKind === 'webcam' && phaseKey === 'overlay') { + els.workflowHintTitle.textContent = 'Overlay attivo sulla webcam'; + els.workflowHintText.textContent = 'Il rendering AR è in tempo reale sul feed live. Le azioni diagnostiche lavorano direttamente sull\'inquadratura corrente.'; + return; + } + + if (sourceKind === 'file' && phaseKey === 'selection') { + els.workflowHintTitle.textContent = 'File caricato, fase di selezione'; + els.workflowHintText.textContent = 'Usa i controlli nativi del video per scegliere il frame o il punto di partenza. Quando sei pronto, premi AVVIA OVERLAY.'; + return; + } + + els.workflowHintTitle.textContent = 'Overlay attivo sul file locale'; + els.workflowHintText.textContent = 'Il video è in riproduzione con tracking attivo. Se devi cambiare punto o clip, ferma l\'overlay e torna alla selezione.'; +} + +function setLog(message, sourcePlugin = null) { + const line = document.createElement('div'); + line.className = 'log-line'; + + if (sourcePlugin) { + const span = document.createElement('span'); + span.style.color = 'var(--accent-2)'; + span.style.fontWeight = '800'; + span.style.marginRight = '8px'; + span.textContent = `[${sourcePlugin.toUpperCase()}]`; + line.appendChild(span); + } + + const textSpan = document.createElement('span'); + textSpan.textContent = message; + line.appendChild(textSpan); + els.logBox.insertBefore(line, els.logBox.firstChild); + + while (els.logBox.children.length > 5) { + els.logBox.removeChild(els.logBox.lastChild); + } +} + +function setStatus(kind, text) { + els.statusDot.className = 'status-dot'; + if (kind === 'live') els.statusDot.classList.add('live'); + if (kind === 'error') els.statusDot.classList.add('error'); + els.statusText.textContent = text; +} + +function setBusy(isBusy) { + isSystemBusy = isBusy; + [els.scanBtn, els.copyMakeupBtn, els.saveBtn, els.findBtn, els.clearDbBtn, els.clearOverlayBtn, els.loadRemoteGhostyleBtn].forEach(btn => { + if (btn === els.copyMakeupBtn && !lastCompositedCanvas) { + btn.disabled = true; + return; + } + + btn.disabled = isBusy; + }); + + const previewBtns = els.ghostylesContainer.querySelectorAll('.preview-btn'); + previewBtns.forEach(btn => { + btn.disabled = isBusy; + }); +} + +function resizeCanvas() { + const rect = els.viewer.getBoundingClientRect(); + els.overlay.width = Math.max(1, Math.floor(rect.width)); + els.overlay.height = Math.max(1, Math.floor(rect.height)); +} + +function clearOverlay() { + const ctx = els.overlay.getContext('2d'); + ctx.clearRect(0, 0, els.overlay.width, els.overlay.height); + els.overlay.style.transition = 'none'; + els.overlay.style.opacity = '1'; + + if (overlayFadeTimeout) clearTimeout(overlayFadeTimeout); +} + +function distance(a, b) { + if (!a || !b || a.length !== b.length) return Number.POSITIVE_INFINITY; + + let sum = 0; + for (let i = 0; i < a.length; i += 1) { + const delta = a[i] - b[i]; + sum += delta * delta; + } + + return Math.sqrt(sum); +} + +function avgPoint(points) { + const total = points.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), { x: 0, y: 0 }); + return { x: total.x / points.length, y: total.y / points.length }; +} + +function lerp(a, b, t) { + return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; +} + +function scaleFrom(center, point, scale) { + return { x: center.x + (point.x - center.x) * scale, y: center.y + (point.y - center.y) * scale }; +} + +function point(x, y) { + return { x, y }; +} + +function drawClosedPath(ctx, points, fillStyle = null, strokeStyle = null, lineWidth = 2) { + if (!points.length) return; + + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y); + ctx.closePath(); + + if (fillStyle) { + ctx.fillStyle = fillStyle; + ctx.fill(); + } + + if (strokeStyle) { + ctx.lineWidth = lineWidth; + ctx.strokeStyle = strokeStyle; + ctx.stroke(); + } +} + +function drawOpenPath(ctx, points, strokeStyle, lineWidth = 2, dashed = false) { + if (!points.length) return; + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i += 1) ctx.lineTo(points[i].x, points[i].y); + ctx.lineWidth = lineWidth; + ctx.strokeStyle = strokeStyle; + if (dashed) ctx.setLineDash([10, 8]); + ctx.stroke(); + ctx.restore(); +} + +function drawLabel(ctx, text, x, y) { + ctx.save(); + ctx.font = '700 14px Inter, system-ui, sans-serif'; + const padX = 10; + const width = ctx.measureText(text).width + padX * 2; + const height = 30; + ctx.fillStyle = 'rgba(15, 17, 21, 0.78)'; + ctx.strokeStyle = 'rgba(255,255,255,0.10)'; + ctx.lineWidth = 1; + roundRect(ctx, x, y - height, width, height, 12); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = 'rgba(238, 242, 255, 0.96)'; + ctx.fillText(text, x + padX, y - 10); + ctx.restore(); +} + +function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +function expandEyePolygon(eye, eyebrow, scale = 1.22, eyebrowLift = 0.72) { + const center = avgPoint(eye); + const topBrow = eyebrow.map((browPoint, index) => { + const eyeRef = eye[Math.min(index + 1, eye.length - 1)] || eye[eye.length - 1]; + return lerp(eyeRef, browPoint, eyebrowLift); + }); + const expandedEye = eye.map(eyePoint => scaleFrom(center, eyePoint, scale)); + return [...topBrow, expandedEye[3], expandedEye[4], expandedEye[5], expandedEye[0]]; +} + +function drawEyeWing(ctx, eye, eyebrow, label, tone) { + const eyeShape = expandEyePolygon(eye, eyebrow, tone.scale, tone.brow); + drawClosedPath(ctx, eyeShape, tone.fill, tone.stroke, 2.2); + + const outerCorner = tone.side === 'left' + ? eye.reduce((best, eyePoint) => (eyePoint.x < best.x ? eyePoint : best), eye[0]) + : eye.reduce((best, eyePoint) => (eyePoint.x > best.x ? eyePoint : best), eye[0]); + const tailTop = point(outerCorner.x + tone.tailX, outerCorner.y - tone.tailY); + const tailLow = point(outerCorner.x + tone.tailX * 0.7, outerCorner.y + tone.tailY * 0.12); + + drawClosedPath(ctx, [outerCorner, tailTop, tailLow], tone.fill, tone.stroke, 2.2); + + const sorted = [...eye].sort((a, b) => a.x - b.x); + const linePoints = tone.side === 'left' + ? [sorted[2], sorted[1], sorted[0], tailTop] + : [sorted[sorted.length - 3], sorted[sorted.length - 2], sorted[sorted.length - 1], tailTop]; + + drawOpenPath(ctx, linePoints, tone.line, 3.2); + drawLabel(ctx, label, tailTop.x + (tone.side === 'left' ? -52 : 10), tailTop.y - 10); +} + +function drawCheekSweep(ctx, anchor, noseSide, mouthCorner, jawPoint, label, fill, stroke) { + const upper = lerp(anchor, noseSide, 0.42); + const lower = lerp(mouthCorner, jawPoint, 0.36); + const side = lerp(anchor, jawPoint, 0.54); + const cheek = [ + upper, + lerp(anchor, side, 0.45), + side, + lower, + lerp(lower, mouthCorner, 0.55), + lerp(mouthCorner, noseSide, 0.42) + ]; + + drawClosedPath(ctx, cheek, fill, stroke, 1.8); + drawLabel(ctx, label, side.x - 20, side.y - 12); +} + +function drawContourBand(ctx, pts, label) { + drawOpenPath(ctx, pts, 'rgba(193, 154, 107, 0.95)', 7, true); + drawOpenPath(ctx, pts, 'rgba(90, 54, 33, 0.22)', 16); + const mid = pts[Math.floor(pts.length / 2)]; + drawLabel(ctx, label, mid.x + 10, mid.y - 6); +} + +window.Ghostati = { + log: (message, sourcePlugin) => setLog(message, sourcePlugin), + distance, + avgPoint, + lerp, + scaleFrom, + point, + drawClosedPath, + drawOpenPath, + drawLabel, + roundRect, + expandEyePolygon, + drawEyeWing, + drawCheekSweep, + drawContourBand +}; + +const loadedGhostyles = new Map(); + +function resolveGhostyleUrl(url) { + return new URL(url, window.location.href).href; +} + +async function loadGhostyle(url, expectedName = null) { + const resolvedUrl = resolveGhostyleUrl(url); + const id = resolvedUrl.split('/').pop().replace('.js', ''); + + try { + setLog(`Caricamento ghostyle da ${resolvedUrl}...`); + + const response = await fetch(resolvedUrl); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const text = await response.text(); + const matchName = text.match(/@name\s+(.+)/); + const name = matchName ? matchName[1].trim() : (expectedName || id); + const module = await import(resolvedUrl); + + loadedGhostyles.set(id, { id, name, module, url: resolvedUrl }); + + const button = document.createElement('button'); + button.className = 'preview-btn'; + button.textContent = name; + button.dataset.effect = id; + button.onclick = () => toggleEffect(id, button); + + els.ghostylesContainer.appendChild(button); + setLog(`Ghostyle '${name}' pronto all'uso.`); + + if (module.onInit) { + module.onInit(); + } + } catch (err) { + console.error(err); + + const button = document.createElement('button'); + button.className = 'preview-btn'; + button.textContent = expectedName || id; + button.disabled = true; + button.classList.add('preview-btn-error'); + button.title = `Errore di caricamento: ${err.message}`; + + els.ghostylesContainer.appendChild(button); + setLog(`Impossibile caricare Ghostyle ${expectedName || id}: ${err.message}`, 'Sistema (Error)'); + } +} + +function drawDetectionScaffold(ctx, resized) { + const box = resized.detection.box; + const landmarks = resized.landmarks; + const leftEye = landmarks.getLeftEye(); + const rightEye = landmarks.getRightEye(); + const nose = landmarks.getNose(); + const jaw = landmarks.getJawOutline(); + const mouth = landmarks.getMouth(); + + ctx.save(); + ctx.lineWidth = 2.2; + ctx.strokeStyle = 'rgba(122, 162, 255, 0.95)'; + ctx.strokeRect(box.x, box.y, box.width, box.height); + + const leftCenter = avgPoint(leftEye); + const rightCenter = avgPoint(rightEye); + ctx.beginPath(); + ctx.moveTo(leftCenter.x, leftCenter.y); + ctx.lineTo(rightCenter.x, rightCenter.y); + ctx.stroke(); + + ctx.strokeStyle = 'rgba(255, 122, 122, 0.85)'; + drawClosedPath(ctx, leftEye, null, 'rgba(255, 122, 122, 0.85)', 2); + drawClosedPath(ctx, rightEye, null, 'rgba(255, 122, 122, 0.85)', 2); + + ctx.strokeStyle = 'rgba(159, 122, 234, 0.88)'; + drawOpenPath(ctx, jaw, 'rgba(159, 122, 234, 0.88)', 2); + ctx.strokeStyle = 'rgba(61, 220, 151, 0.88)'; + drawOpenPath(ctx, nose, 'rgba(61, 220, 151, 0.88)', 2); + ctx.strokeStyle = 'rgba(255, 204, 102, 0.88)'; + drawClosedPath(ctx, mouth, null, 'rgba(255, 204, 102, 0.88)', 2); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.92)'; + [leftCenter, rightCenter, avgPoint(nose.slice(3)), avgPoint(mouth.slice(0, 7))].forEach(marker => { + ctx.beginPath(); + ctx.arc(marker.x, marker.y, 3.4, 0, Math.PI * 2); + ctx.fill(); + }); + + const lines = ['volto rilevato']; + if (typeof resized.age === 'number') lines.push(`eta stimata: ${Math.round(resized.age)}`); + if (resized.gender) lines.push(`genere stimato: ${resized.gender}`); + new faceapi.draw.DrawTextField(lines, { x: box.x, y: Math.max(16, box.y - 8) }).draw(els.overlay); + ctx.restore(); +} + +function drawEffectOverlay(result, includeDetectionScaffold = false) { + resizeCanvas(); + const ctx = els.overlay.getContext('2d'); + ctx.clearRect(0, 0, els.overlay.width, els.overlay.height); + + const resized = faceapi.resizeResults(result, { width: els.overlay.width, height: els.overlay.height }); + if (includeDetectionScaffold) drawDetectionScaffold(ctx, resized); + + if (activeEffect) { + const style = loadedGhostyles.get(activeEffect); + if (style && style.module.onDraw) { + ctx.save(); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + style.module.onDraw(ctx, resized.landmarks, resized.detection.box); + ctx.restore(); + } + } + + lastKnownEffectResult = result; +} + +async function detectCurrentFace(drawOverlay = true) { + clearOverlay(); + + const result = await faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS) + .withFaceLandmarks() + .withAgeAndGender() + .withFaceDescriptor(); + + if (!result) { + lastKnownEffectResult = null; + setLog('Nessun volto rilevato nella sorgente attiva.'); + return null; + } + + if (drawOverlay) drawResult(result); + return result; +} + +function drawResult(result) { + resizeCanvas(); + const ctx = els.overlay.getContext('2d'); + ctx.clearRect(0, 0, els.overlay.width, els.overlay.height); + + const resized = faceapi.resizeResults(result, { width: els.overlay.width, height: els.overlay.height }); + drawDetectionScaffold(ctx, resized); + + if (activeEffect) { + const style = loadedGhostyles.get(activeEffect); + if (style && style.module.onDraw) { + ctx.save(); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + style.module.onDraw(ctx, resized.landmarks, resized.detection.box); + ctx.restore(); + } + } + + lastKnownEffectResult = result; +} + +async function runEffectPass() { + if (!activeEffect || isSystemBusy || effectInferenceInFlight || els.video.readyState < 2) return; + + effectInferenceInFlight = true; + try { + const result = await faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS).withFaceLandmarks(); + if (!result) { + lastKnownEffectResult = null; + clearOverlay(); + return; + } + + drawEffectOverlay(result, false); + } catch (err) { + console.error(err); + } finally { + effectInferenceInFlight = false; + } +} + +function scheduleEffectLoop() { + if ('requestVideoFrameCallback' in els.video && phaseController && phaseController.source && phaseController.source.kind === 'file') { + effectLoopUsesVideoFrames = true; + effectLoopHandle = els.video.requestVideoFrameCallback(effectLoop); + return; + } + + effectLoopUsesVideoFrames = false; + effectLoopHandle = requestAnimationFrame(effectLoop); +} + +function effectLoop(now) { + if (!activeEffect || !phaseController || phaseController.phase !== 'OVERLAY') { + effectLoopHandle = null; + effectLoopUsesVideoFrames = false; + return; + } + + const currentDelay = parseInt(els.fpsSelect.value, 10) || 120; + const timestamp = typeof now === 'number' ? now : performance.now(); + + if (timestamp - lastEffectRun > currentDelay) { + lastEffectRun = timestamp; + runEffectPass(); + } + + scheduleEffectLoop(); +} + +function startEffectLoop() { + if (effectLoopHandle) stopEffectLoop(); + scheduleEffectLoop(); +} + +function stopEffectLoop() { + if (effectLoopHandle) { + if (effectLoopUsesVideoFrames && els.video.cancelVideoFrameCallback) { + els.video.cancelVideoFrameCallback(effectLoopHandle); + } else { + cancelAnimationFrame(effectLoopHandle); + } + } + + effectLoopHandle = null; + effectLoopUsesVideoFrames = false; + effectInferenceInFlight = false; +} + +function deactivateEffect({ silent = false } = {}) { + if (activeEffect) { + const style = loadedGhostyles.get(activeEffect); + if (style && style.module.onClear) { + style.module.onClear(els.overlay.getContext('2d')); + } + } + + activeEffect = null; + stopEffectLoop(); + + const previewBtns = els.ghostylesContainer.querySelectorAll('.preview-btn'); + previewBtns.forEach(btn => btn.classList.remove('active')); + + els.scanBtn.textContent = 'Scansiona faccia'; + els.scanBtn.classList.remove('is-effect-active'); + + updateEffectStats(); + lastKnownEffectResult = null; + lastCompositedCanvas = null; + els.copyMakeupBtn.disabled = true; + clearOverlay(); + + if (!silent) { + setLog('Guida makeup disattivata. Sorgente video ripristinata senza overlay.'); + } +} + +function toggleEffect(effect, button) { + if (activeEffect === effect) { + deactivateEffect(); + return; + } + + if (activeEffect) { + deactivateEffect({ silent: true }); + } + + activeEffect = effect; + const previewBtns = els.ghostylesContainer.querySelectorAll('.preview-btn'); + previewBtns.forEach(previewBtn => previewBtn.classList.toggle('active', previewBtn === button)); + els.previewImage.style.display = 'none'; + els.previewImage.removeAttribute('src'); + updateEffectStats(); + + const style = loadedGhostyles.get(effect); + setLog(`Guida makeup attiva: ${style ? style.name : effect}. Cerca un volto nel feed attivo per vedere dove applicarlo.`); + + els.scanBtn.textContent = 'Scansiona trucco'; + els.scanBtn.classList.add('is-effect-active'); + + if (overlayFadeTimeout) clearTimeout(overlayFadeTimeout); + els.overlay.style.transition = 'none'; + els.overlay.style.opacity = '1'; + + if (phaseController && phaseController.phase === 'OVERLAY') { + startEffectLoop(); + } +} + +async function scanFace() { + const result = await detectCurrentFace(true); + if (!result) return; + + triggerOverlayFadeout(); + const age = Math.round(result.age); + const gender = result.gender || 'n/d'; + const confidence = typeof result.genderProbability === 'number' ? ` (${Math.round(result.genderProbability * 100)}%)` : ''; + setLog(`Volto trovato. Età stimata: ${age}. Genere stimato: ${gender}${confidence}. Overlay biometrico aggiornato.`); +} + +async function saveFace() { + const result = await detectCurrentFace(true); + if (!result) return; + + triggerOverlayFadeout(); + const id = db.nextId; + db.nextId += 1; + db.faces.push({ + id, + descriptor: Array.from(result.descriptor), + age: Math.round(result.age), + gender: result.gender || null, + savedAt: new Date().toISOString() + }); + persistDb(); + setLog(`Impronta biometrica salvata con ID ${id}. Archivio locale aggiornato.`); +} + +async function findFace() { + if (db.faces.length === 0) { + setLog('Archivio locale vuoto. Salva almeno un volto prima della ricerca.'); + clearOverlay(); + return; + } + + const result = await detectCurrentFace(true); + if (!result) return; + + triggerOverlayFadeout(); + const matches = db.faces + .map(entry => ({ id: entry.id, distance: distance(result.descriptor, entry.descriptor) })) + .filter(entry => entry.distance <= MATCH_THRESHOLD) + .sort((a, b) => a.distance - b.distance); + + if (!matches.length) { + setLog(`Nessuna corrispondenza trovata sotto soglia ${MATCH_THRESHOLD.toFixed(2)}.`); + return; + } + + const summary = matches.map(match => `${match.id} (${match.distance.toFixed(3)})`).join(', '); + setLog(`Corrispondenze trovate: ${summary}.`); +} + +async function loadModels() { + setStatus('init', 'caricamento modelli'); + setLog('Caricamento modelli face-api.js in corso…'); + + await Promise.all([ + faceapi.nets.tinyFaceDetector.loadFromUri('https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js-models@master/tiny_face_detector'), + faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URLS.landmarks), + faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URLS.recognition), + faceapi.nets.ageGenderNet.loadFromUri(MODEL_URLS.ageGender) + ]); +} + +function clearDb() { + db = { nextId: 0, faces: [] }; + persistDb(); + setLog('Archivio locale cancellato. Il contatore ID riparte da 0.'); +} + +function handleError(err, fallbackMessage) { + console.error(err); + setStatus('error', 'errore'); + els.placeholder.style.display = 'grid'; + const detail = err && err.message ? ` (${err.message})` : ''; + setLog(fallbackMessage + detail); +} + +async function testMakeupEfficacy() { + const result = await detectCurrentFace(false); + if (!result) { + setLog('Nessun volto di base trovato nella sorgente attiva.'); + return; + } + + const canvas = document.createElement('canvas'); + canvas.width = els.overlay.width; + canvas.height = els.overlay.height; + const ctx = canvas.getContext('2d'); + + ctx.drawImage(els.video, 0, 0, canvas.width, canvas.height); + + const style = loadedGhostyles.get(activeEffect); + if (style && style.module.onDraw) { + ctx.save(); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + const resized = faceapi.resizeResults(result, { width: canvas.width, height: canvas.height }); + style.module.onDraw(ctx, resized.landmarks, resized.detection.box); + ctx.restore(); + } + + lastCompositedCanvas = canvas; + els.copyMakeupBtn.disabled = false; + setLog('Analisi in corso... sottopongo il compositing a face-api'); + + const obfuscatedResult = await faceapi.detectSingleFace(canvas, DETECTOR_OPTIONS) + .withFaceLandmarks() + .withFaceDescriptor(); + + if (!obfuscatedResult) { + setLog('Risultato: ECCELLENTE. Il trucco ha frammentato il volto al punto da distruggere l\'algoritmo (nessun volto rilevato).'); + return; + } + + const dist = distance(result.descriptor, obfuscatedResult.descriptor); + if (dist > MATCH_THRESHOLD) { + setLog(`Risultato: BUONO (Spoofed). Volto individuato, ma l'identità biometrica è irriconoscibile (distanza: ${dist.toFixed(3)}).`); + } else { + setLog(`Risultato: INSUFFICIENTE. Il sistema ti riconosce ancora perfettamente (distanza: ${dist.toFixed(3)} <= ${MATCH_THRESHOLD.toFixed(2)}). Aggiungi geometrie.`); + } +} + +async function init() { + renderDbStats(); + updateEffectStats(); + resizeCanvas(); + + phaseController = createPhaseController({ + els, + resizeCanvas, + stopEffectLoop, + startEffectLoop, + hasActiveEffect: () => Boolean(activeEffect), + setLog, + onStateChange: ({ source, phase }) => updateWorkflowGuide(source, phase) + }); + + updateWorkflowGuide(); + + window.addEventListener('resize', resizeCanvas); + window.addEventListener('beforeunload', stopMemoryMonitor); + + els.copyMakeupBtn.addEventListener('click', async () => { + if (!lastCompositedCanvas) return; + + try { + const exportCanvas = document.createElement('canvas'); + const headerHeight = 44; + const footerHeight = 50; + exportCanvas.width = lastCompositedCanvas.width; + exportCanvas.height = lastCompositedCanvas.height + headerHeight + footerHeight; + const ctx = exportCanvas.getContext('2d'); + + ctx.fillStyle = '#0f1115'; + ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + ctx.drawImage(lastCompositedCanvas, 0, headerHeight); + ctx.fillStyle = '#eef2ff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const style = loadedGhostyles.get(activeEffect); + const pluginName = style ? style.name : 'Unknown Plugin'; + ctx.font = 'bold 14px Inter, sans-serif'; + ctx.fillText(`github.com/vecna/antagonistrucco | Modulo attivo: ${pluginName}`, exportCanvas.width / 2, headerHeight / 2); + + const logText = els.logBox.firstChild ? els.logBox.firstChild.textContent : ''; + ctx.font = '14px Inter, sans-serif'; + if (logText.includes('ECCELLENTE') || logText.includes('BUONO')) ctx.fillStyle = '#3ddc97'; + else if (logText.includes('INSUFFICIENTE')) ctx.fillStyle = '#ff7a7a'; + else ctx.fillStyle = '#eef2ff'; + + ctx.fillText(logText, exportCanvas.width / 2, exportCanvas.height - footerHeight / 2); + + exportCanvas.toBlob(blob => { + navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then(() => { + setLog('Immagine con referto diagnostico copiata negli appunti!'); + }); + }); + } catch (err) { + console.error(err); + setLog('Errore durante la copia. Forse manca il permesso nel browser?'); + } + }); + + els.scanBtn.addEventListener('click', async () => { + setBusy(true); + try { + if (activeEffect) { + await testMakeupEfficacy(); + } else { + await scanFace(); + } + } catch (err) { + handleError(err, 'Errore durante la scansione o l\'analisi avversaria.'); + } finally { + setBusy(false); + if (activeEffect && phaseController && phaseController.phase === 'OVERLAY') startEffectLoop(); + } + }); + + els.saveBtn.addEventListener('click', async () => { + setBusy(true); + try { + await saveFace(); + } catch (err) { + handleError(err, 'Errore durante il salvataggio del volto.'); + } finally { + setBusy(false); + if (activeEffect && phaseController && phaseController.phase === 'OVERLAY') startEffectLoop(); + } + }); + + els.findBtn.addEventListener('click', async () => { + setBusy(true); + try { + await findFace(); + } catch (err) { + handleError(err, 'Errore durante la ricerca del volto.'); + } finally { + setBusy(false); + if (activeEffect && phaseController && phaseController.phase === 'OVERLAY') startEffectLoop(); + } + }); + + els.clearDbBtn.addEventListener('click', () => { + if (els.clearDbBtn.textContent === 'Conferma azzeramento?') { + clearDb(); + els.clearDbBtn.textContent = 'Azzera archivio locale'; + return; + } + + els.clearDbBtn.textContent = 'Conferma azzeramento?'; + setTimeout(() => { + if (els.clearDbBtn.textContent === 'Conferma azzeramento?') { + els.clearDbBtn.textContent = 'Azzera archivio locale'; + } + }, 4000); + }); + + els.clearOverlayBtn.addEventListener('click', () => { + if (activeEffect) deactivateEffect({ silent: true }); + clearOverlay(); + setLog('Overlay pulito.'); + }); + + els.loadRemoteGhostyleBtn.addEventListener('click', async () => { + const url = els.remoteGhostyleUrl.value.trim(); + if (!url) return; + + els.loadRemoteGhostyleBtn.disabled = true; + await loadGhostyle(url); + els.remoteGhostyleUrl.value = ''; + els.loadRemoteGhostyleBtn.disabled = false; + }); + + els.srcWebcamBtn.addEventListener('click', async () => { + els.fileInfoBox.style.display = 'none'; + try { + await phaseController.enterSelection(new WebcamSource()); + } catch (err) { + handleError(err, 'Impossibile accedere alla webcam.'); + } + }); + + els.srcFileInput.addEventListener('change', async event => { + const file = event.target.files[0]; + if (!file) return; + + els.fileInfoBox.style.display = 'block'; + els.fileSizeLabel.textContent = `File in uso: ${file.name} (${formatBytes(file.size)})`; + + try { + await phaseController.enterSelection(new FileSource(file)); + } catch (err) { + handleError(err, `Errore nel caricamento del file video: ${err.message}`); + } + + els.srcFileInput.value = ''; + }); + + els.phaseStartBtn.addEventListener('click', () => { + phaseController.enterOverlay(); + }); + + els.phaseStopBtn.addEventListener('click', () => { + if (phaseController.source) { + phaseController.enterSelection(phaseController.source); + } + }); + + setBusy(true); + try { + await loadModels(); + setStatus('init', 'In attesa sorgente'); + setLog('Modelli pronti. Seleziona la webcam o carica un file per iniziare.'); + + try { + const ghostylistRes = await fetch('ghostylist.json'); + if (ghostylistRes.ok) { + const list = await ghostylistRes.json(); + for (const item of list) { + await loadGhostyle(item.url, item.name); + } + } else { + setLog('File ghostylist.json non trovato, caricamento plugin saltato.', 'Sistema'); + } + } catch (err) { + setLog(`Errore durante la lettura di ghostylist.json: ${err.message}`, 'Sistema'); + } + } catch (err) { + handleError(err, 'Impossibile inizializzare i modelli.'); + return; + } finally { + setBusy(false); + } +} + +init(); \ No newline at end of file diff --git a/scripts/video.js b/scripts/video.js new file mode 100644 index 0000000..0d28457 --- /dev/null +++ b/scripts/video.js @@ -0,0 +1,146 @@ +function getSourceLabel(sourceKind) { + return sourceKind === 'file' ? 'FILE LOCALE' : 'WEBCAM'; +} + +export function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const unitBase = 1024; + const precision = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const unitIndex = Math.floor(Math.log(bytes) / Math.log(unitBase)); + + return `${parseFloat((bytes / Math.pow(unitBase, unitIndex)).toFixed(precision))} ${sizes[unitIndex]}`; +} + +export function startMemoryMonitor(memoryLabelEl, intervalMs = 2000) { + if (!memoryLabelEl) return () => {}; + + const intervalId = window.setInterval(() => { + if (!window.performance || !performance.memory) return; + + const memory = performance.memory; + memoryLabelEl.textContent = `JS Heap: ${formatBytes(memory.usedJSHeapSize)} / Limite: ${formatBytes(memory.jsHeapSizeLimit)}`; + }, intervalMs); + + return () => window.clearInterval(intervalId); +} + +export class FileSource { + constructor(file) { + this.file = file; + this.kind = 'file'; + this.supportsSelectionControls = true; + this.url = null; + } + + async attach(videoEl) { + if (videoEl.canPlayType(this.file.type) === '') { + throw new Error(`Formato video non supportato: ${this.file.type}`); + } + + if (this.url) { + URL.revokeObjectURL(this.url); + this.url = null; + } + + videoEl.srcObject = null; + this.url = URL.createObjectURL(this.file); + videoEl.src = this.url; + videoEl.muted = true; + videoEl.playsInline = true; + + return new Promise((resolve, reject) => { + videoEl.onloadedmetadata = () => resolve(); + videoEl.onerror = () => reject(new Error('Impossibile caricare il video.')); + }); + } + + detach(videoEl) { + videoEl.pause(); + videoEl.removeAttribute('src'); + videoEl.load(); + + if (this.url) { + URL.revokeObjectURL(this.url); + this.url = null; + } + } +} + +export function createPhaseController({ + els, + resizeCanvas, + stopEffectLoop, + startEffectLoop, + hasActiveEffect, + setLog, + onStateChange +}) { + return { + source: null, + phase: null, + + async enterSelection(newSource) { + const isSameSource = this.source === newSource; + + if (this.source && !isSameSource) { + this.source.detach(els.video); + } + + this.source = newSource; + this.phase = 'SELECTION'; + + stopEffectLoop(); + + els.video.controls = this.source.supportsSelectionControls; + els.video.loop = false; + els.phaseTransitionBox.style.display = 'block'; + els.phaseStartBtn.style.display = 'inline-block'; + els.phaseStopBtn.style.display = 'none'; + els.videoSourceIndicator.textContent = `SORGENTE: ${getSourceLabel(this.source.kind)} - FASE SELEZIONE`; + + setLog(`Fase attivata: SELEZIONE (${this.source.kind}). Scegli il punto di partenza o premi AVVIA OVERLAY.`, 'Sistema'); + + if (!isSameSource) { + await this.source.attach(els.video); + } + + resizeCanvas(); + + if (this.source.kind === 'webcam') { + await els.video.play(); + } else { + els.video.pause(); + } + + els.placeholder.style.display = 'none'; + if (onStateChange) onStateChange({ source: this.source, phase: this.phase }); + }, + + async enterOverlay() { + if (!this.source || this.phase === 'OVERLAY') return; + + this.phase = 'OVERLAY'; + els.video.controls = false; + els.phaseStartBtn.style.display = 'none'; + els.phaseStopBtn.style.display = 'inline-block'; + els.videoSourceIndicator.textContent = `SORGENTE: ${getSourceLabel(this.source.kind)} - FASE OVERLAY`; + + setLog(`Fase attivata: OVERLAY (${this.source.kind}). Avvio elaborazione video in tempo reale.`, 'Sistema'); + + if (this.source.kind === 'file') { + els.video.loop = true; + await els.video.play(); + } else if (els.video.paused) { + await els.video.play(); + } + + if (hasActiveEffect()) { + startEffectLoop(); + } + + if (onStateChange) onStateChange({ source: this.source, phase: this.phase }); + } + }; +} \ No newline at end of file diff --git a/scripts/webcam.js b/scripts/webcam.js new file mode 100644 index 0000000..d0a9623 --- /dev/null +++ b/scripts/webcam.js @@ -0,0 +1,61 @@ +export class WebcamSource { + constructor() { + this.kind = 'webcam'; + this.supportsSelectionControls = false; + this.stream = null; + } + + async attach(videoEl) { + if (this.stream) return; + + this.stream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 1920 }, + height: { ideal: 1080 }, + facingMode: 'user' + }, + audio: false + }); + + videoEl.srcObject = this.stream; + videoEl.muted = true; + videoEl.playsInline = true; + + return new Promise(resolve => { + videoEl.onloadedmetadata = () => resolve(); + }); + } + + detach(videoEl) { + if (this.stream) { + this.stream.getTracks().forEach(track => track.stop()); + this.stream = null; + } + + videoEl.srcObject = null; + } +} + +export function createMirrorController({ buttonEl, videoEl, overlayEl }) { + let isMirrored = false; + + const applyMirrorState = () => { + const scale = isMirrored ? 'scaleX(-1)' : 'scaleX(1)'; + videoEl.style.transform = scale; + overlayEl.style.transform = scale; + buttonEl.classList.toggle('mirrored', isMirrored); + buttonEl.textContent = isMirrored ? 'Webcam speculare: ON' : 'Mirror webcam'; + }; + + buttonEl.addEventListener('click', () => { + isMirrored = !isMirrored; + applyMirrorState(); + }); + + applyMirrorState(); + + return { + isMirrored: () => isMirrored, + applyMirrorState + }; +} \ No newline at end of file diff --git a/styles/ghostati-face-api.css b/styles/ghostati-face-api.css index 37de67c..9270314 100644 --- a/styles/ghostati-face-api.css +++ b/styles/ghostati-face-api.css @@ -72,6 +72,14 @@ h1 { max-width: 80ch; } +.link-accent { + color: var(--accent-2); +} + +.link-accent:hover { + color: #ffffff; +} + .about { background: rgba(15, 17, 21, 0.46); border: 1px solid rgba(255, 255, 255, 0.06); @@ -129,6 +137,34 @@ h1 { line-height: 1.45; } +.panel-header-source { + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 16px; + text-align: center; + margin-bottom: 16px; +} + +.panel-header-diagnosis { + background: linear-gradient(135deg, rgba(159, 122, 234, 0.12), rgba(122, 162, 255, 0.08)); + border-bottom: 1px solid rgba(159, 122, 234, 0.3); + padding-bottom: 20px; + text-align: center; +} + +.panel-eyebrow-title { + color: var(--text); + margin-bottom: 8px; + font-size: 15px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.panel-eyebrow-title-accent { + color: var(--accent-2); + margin-bottom: 12px; +} + .controls, .makeup { display: flex; @@ -143,6 +179,10 @@ h1 { gap: 12px; } +.stack-compact { + gap: 8px; +} + .action-btn, .preview-btn, .secondary-btn { @@ -178,6 +218,28 @@ h1 { background: linear-gradient(180deg, rgba(122, 162, 255, 0.24), rgba(122, 162, 255, 0.14)); } +.action-btn-source { + background: linear-gradient(180deg, rgba(122, 162, 255, 0.92), rgba(122, 162, 255, 0.72)); + color: var(--bg); + font-weight: 800; +} + +.action-btn-scan { + transition: all 0.3s ease; +} + +.action-btn-scan.is-effect-active { + background: linear-gradient(180deg, rgba(159, 122, 234, 0.35), rgba(159, 122, 234, 0.15)); + border-color: rgba(159, 122, 234, 0.5); + color: #fff; +} + +.action-btn-start-overlay { + background: linear-gradient(180deg, rgba(61, 220, 151, 0.95), rgba(61, 220, 151, 0.72)); + color: #04130b; + font-weight: 800; +} + .secondary-btn { background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)); font-size: 13px; @@ -185,6 +247,17 @@ h1 { color: var(--muted); } +.secondary-btn-copy { + margin-top: 8px; + font-size: 11px; +} + +.secondary-btn-stop { + display: none; + color: var(--danger); + border-color: rgba(255, 122, 122, 0.4); +} + .preview-btn { background: linear-gradient(180deg, rgba(159, 122, 234, 0.25), rgba(159, 122, 234, 0.12)); text-align: left; @@ -195,6 +268,11 @@ h1 { border-color: rgba(159, 122, 234, 0.7); } +.preview-btn-error { + color: var(--danger); + border-color: rgba(255, 122, 122, 0.4); +} + .small-note { margin: 0; color: var(--muted); @@ -203,6 +281,49 @@ h1 { padding: 0 16px 16px; } +.source-indicator { + font-size: 12px; + margin-bottom: 12px; + color: var(--muted); + font-weight: 700; +} + +.file-upload-label { + cursor: pointer; + display: block; + text-align: center; +} + +.file-input-hidden { + display: none; +} + +.file-info-box { + display: none; + font-size: 11px; + color: var(--muted); + line-height: 1.4; + padding: 4px; + text-align: left; +} + +.file-warning-label { + color: var(--danger); +} + +.memory-label { + display: block; + margin-top: 4px; + color: var(--accent-2); +} + +.phase-transition-box { + display: none; + margin-top: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.05); + padding-top: 12px; +} + .image-panel { display: grid; grid-template-rows: auto 1fr auto; @@ -360,6 +481,103 @@ h1 { padding: 12px 14px; } +.workflow-card { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)); +} + +.workflow-badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.workflow-badge { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 800; +} + +.workflow-badge-source { + background: rgba(122, 162, 255, 0.16); + color: #d9e5ff; +} + +.workflow-badge-phase { + background: rgba(255, 255, 255, 0.06); + color: var(--muted); +} + +.workflow-badge-phase.is-selection { + background: rgba(255, 204, 102, 0.15); + border-color: rgba(255, 204, 102, 0.25); + color: #ffe29c; +} + +.workflow-badge-phase.is-overlay { + background: rgba(61, 220, 151, 0.15); + border-color: rgba(61, 220, 151, 0.25); + color: #9cf0c9; +} + +.workflow-hint-title { + margin: 0 0 6px; + font-size: 14px; + font-weight: 700; + color: var(--text); +} + +.workflow-hint-text { + margin: 0 0 12px; + font-size: 12px; + line-height: 1.5; + color: var(--muted); +} + +.workflow-grid { + display: grid; + gap: 8px; +} + +.workflow-mode-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); + transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease, opacity 0.18s ease; +} + +.workflow-mode-card strong { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.workflow-mode-card span { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.workflow-mode-card.is-active { + background: rgba(122, 162, 255, 0.08); + border-color: rgba(122, 162, 255, 0.38); + transform: translateY(-1px); +} + +.workflow-mode-card.is-dimmed { + opacity: 0.55; +} + .list-card h3 { margin: 0 0 8px; font-size: 13px; @@ -388,3 +606,62 @@ h1 { .metric strong { font-weight: 700; } + +.select-wrap { + padding: 0 16px 12px; +} + +.fps-select { + width: 100%; + padding: 10px 14px; + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + color: var(--muted); + font-size: 13px; + font-weight: 600; + text-align: left; + cursor: pointer; + outline: none; + appearance: none; + -webkit-appearance: none; +} + +.ghostyle-details { + padding-top: 0; + border-top: 1px solid rgba(255, 255, 255, 0.05); + margin-top: 0; + background-color: var(--accent-2); + border-radius: 12px; +} + +.ghostyle-url-input { + width: 100%; + padding: 10px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 10px; + color: var(--bg); + font-size: 12px; + text-align: left; + cursor: text; +} + +.ghostyle-url-input::placeholder { + color: rgba(15, 17, 21, 0.55); +} + +.ghostyle-load-btn { + margin-top: -6px; +} + +.btn-link { + display: block; + text-decoration: none; +} + +.ghostyle-docs-link { + text-align: center; + color: var(--bg); + font-weight: 700; +}