scripts/main.jsjavascript
/** @module main */
import { distance, avgPoint, lerp, scaleFrom, point, drawClosedPath, drawOpenPath, drawLabel, roundRect, expandEyePolygon, drawEyeWing, drawCheekSweep, drawContourBand, clipLeftHalf, clipRightHalf, clipLeftHalfUV, clipRightHalfUV, setLog, updateLogDisplay } from './utils.js';
import { state } from './state.js';
import { loadDb, loadDb3d, persistDb, persistDb3d, renderDbStats, clearDb } from './db.js';
import { saveFace, compositeAndDetect } from './engine.js';
import { loadMobileNet, saveFace3d, compositeAndDetect3d } from './engine-3d.js';
import { startCamera, resizeCanvas, startEffectLoop, recordOneSecond } from './camera.js';
import { MODEL_URLS, DETECTOR_OPTIONS } from './config.js';
import { els, setStatus, clearOverlay } from './dom.js';
import { loadGhostyle, reloadPlugins } from './ghostyles-manager.js';
import { initPlugins3dLoader, getActiveEffect3d, activateEffect3d, deactivateEffect3d, toggleEffect3d, reloadPlugins3d } from './plugins3d-loader.js';
import { exportMakeup } from './export-makeup.js';
import { setOverlayMode, OVERLAY_MODE_STORAGE_KEY, OVERLAY_MODES } from './bbox-overlay.js';
import { openAnalyzePanel } from './analyze-panel.js';
import { captureThumbnail, deleteThumbnail, getThumbnail, saveThumbnail } from './face-thumbnails.js';
import { applyI18n, initI18n, setupLocaleSelect, t } from './i18n.js';

function overlayModeLabel(mode) {
   return OVERLAY_MODES[mode] || OVERLAY_MODES.bbox;
}

function readInitialOverlayMode() {
   try {
      const raw = localStorage.getItem(OVERLAY_MODE_STORAGE_KEY);
      return Object.keys(OVERLAY_MODES).includes(raw) ? raw : 'bbox';
   } catch {
      return 'bbox';
   }
}

function isLocalPluginDevHost() {
   const host = window.location.hostname;
   return host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local');
}

// Mirror toggle logic (fallback, hidden in UI)
if (els.mirrorToggle) {
   els.mirrorToggle.addEventListener('click', () => {
      state.isMirrored = !state.isMirrored;
      els.video.style.transform = state.isMirrored ? 'scaleX(-1)' : 'scaleX(1)';
      els.overlay.style.transform = state.isMirrored ? 'scaleX(-1)' : 'scaleX(1)';
      els.mirrorToggle.classList.toggle('mirrored', state.isMirrored);
      els.mirrorToggle.textContent = state.isMirrored ? t('mirror_webcam_on_status') : t('mirror_webcam_button');
   });
}

// Switch Camera logic
if (els.switchCameraBtn) {
   els.switchCameraBtn.addEventListener('click', async () => {
      state.currentFacingMode = state.currentFacingMode === 'user' ? 'environment' : 'user';
      setLog(t('switching_camera_log', { mode: state.currentFacingMode }));
      if (els.video.srcObject) {
         els.video.srcObject.getTracks().forEach(track => track.stop());
      }
      try {
         await startCamera(state, els);
      } catch (err) {
         handleError(err, t('camera_switch_error'));
      }
   });
}


window.gstmxx = {
   log: (message, sourcePlugin) => setLog(message, sourcePlugin),
   clearVisibleLogs: () => {
      state.visibleLogStartIndex = state.logsArchive.length;
      updateLogDisplay();
   },
   /* queste le funzioni utili nei plugin */
   distance,
   avgPoint,
   lerp,
   scaleFrom,
   point,
   drawClosedPath,
   drawOpenPath,
   drawLabel,
   roundRect,
   expandEyePolygon,
   drawEyeWing,
   drawCheekSweep,
   drawContourBand,
   clipLeftHalf,
   clipRightHalf,
   clipLeftHalfUV,
   clipRightHalfUV,
   /* fine delle funzioni usate nei plugin, ora implementate in utils.js */
   events: state.gstmxxEvents,
   getDb: () => structuredClone(state.db),
   getDb3d: () => structuredClone(state.db3d),
   getActiveEffect: () => state.activeEffect,
   getLastResult: () => state.lastKnownEffectResult,
   getMatchThreshold: () => state.MATCH_THRESHOLD,
   getMatchThreshold3d: () => state.MATCH_THRESHOLD_3D,
   getActiveEffect3d: () => getActiveEffect3d(),
   activateEffect3d: (id) => activateEffect3d(id),
   deactivateEffect3d: () => deactivateEffect3d(),
   toggleEffect3d: (id) => toggleEffect3d(id),
   reloadPlugins3d: () => reloadPlugins3d(),
   reloadPlugins: async () => reloadPlugins({
      onFaceapiToggle: () => startEffectLoop(state, els)
   }),
   get lastLandmarks3d() { return state.lastLandmarks3d; },
   set lastLandmarks3d(v) { state.lastLandmarks3d = v; },
   compositeAndDetect: (liveResult) => compositeAndDetect(liveResult),
   compositeAndDetect3d: () => compositeAndDetect3d(),
   detectorOptions: DETECTOR_OPTIONS
};

// International alias (see redesign/ghostati-ghostmaxxing-naming-brief.txt)
window.gstmxx = window.gstmxx;

/**
 * Sets the busy flag for the whole UI, disabling/enabling controls during asynchronous operations.
 *
 * @param {boolean} isBusy - When true, UI controls are disabled to prevent concurrent actions.
 * @see init – called during startup to manage UI state while models load.
 * @see loadModels – UI is set busy while models are loading.
 * @see toggleEffect – disables controls while an effect is being applied.
 */
export function setBusy(isBusy) {
   state.isSystemBusy = isBusy;
   [els.copyMakeupBtn, els.saveBtn, els.analyzeBtn, els.overlayModeBtn, els.clearDbBtn, els.recordBtn, els.reloadPluginsBtn].forEach(btn => {
      if (btn) {
         if (btn === els.copyMakeupBtn && !state.lastCompositedCanvas) btn.disabled = true;
         else if (btn === els.recordBtn && state.isRecording) btn.disabled = true;
         else btn.disabled = isBusy;
      }
   });
   const previewBtns = els.ghostylesContainer.querySelectorAll('.preview-btn');
   previewBtns.forEach(btn => btn.disabled = isBusy);
}

/**
 * Loads all required face-api.js models for facial detection, landmarks, recognition, and age/gender.
 * This is called during application startup to ensure the models are cached before any webcam processing.
 *
 * @see init – the main initialization routine that invokes `loadModels` before starting the camera.
 * @see setStatus – updates UI status while models are being loaded.
 */
async function loadModels() {
   setStatus('init', t('loading_models_status'));
   await Promise.all([
      faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URLS.tiny),
      faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URLS.landmarks),
      faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URLS.recognition),
      faceapi.nets.ageGenderNet.loadFromUri(MODEL_URLS.ageGender),
      faceapi.nets.faceExpressionNet.loadFromUri(MODEL_URLS.expressions)
   ]);
}

/**
 * Centralized error handling for UI actions.
 * Logs the fallback message, the error details, updates the status UI, shows the placeholder, and records the full message in the log.
 *
 * @param {Error} err - The caught error object.
 * @param {string} fallbackMessage - User‑friendly message describing the context of the error.
 * @see init – uses handleError for camera initialization failures.
 * @see toggleEffect – uses handleError when scanning or applying effects fails.
 */
function handleError(err, fallbackMessage) {
   console.log(t('error_console_prefix'), fallbackMessage);
   console.error(err);
   setStatus('error', t('error_status'));
   els.placeholder.style.display = 'grid';
   const detail = err && err.message ? ` (${err.message})` : '';
   setLog(fallbackMessage + detail);
}

// ─────────────────────────────────────────────────────────────────────────────
// Unified payload helpers
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Compute the `overall` field of a matchStateChanged payload.
 * @param {object|null} faceapiSection
 * @param {object|null} mediapipeSection
 * @returns {'matched'|'eluded'|'partial-elusion'|'unknown'}
 */
function computeOverall(faceapiSection, mediapipeSection) {
   if (!faceapiSection && !mediapipeSection) return 'unknown';
   const f = faceapiSection?.detectionState;
   const m = mediapipeSection?.detectionState;
   if (!f || !m) return 'unknown';
   if (f === m) return f; // both 'matched' or both 'eluded'
   return 'partial-elusion';
}

/**
 * Build the `faceapi` section for a save action.
 * After saving, the best match IS the saved face (dist ≈ 0), so we report
 * detectionState: 'matched' unconditionally.
 * @param {{ id, result }} saved2d
 * @returns {object}
 */
function buildFaceapiSaveSection(saved2d) {
   return {
      detectionState: 'matched',
      distance: 0,
      matchedId: saved2d.id,
      liveMinDist: 0,
      liveMinId: saved2d.id,
      obfMinDist: null,
      obfMinId: null,
   };
}

/**
 * Build the `mediapipe` section for a save action.
 * @param {{ id, liveInfo3d }} saved3d
 * @param {number} id
 * @returns {object}
 */
function buildMediapipeSaveSection(saved3d, id) {
   return {
      detectionState: 'matched',
      similarity: 1.0, // just saved — perfect self-match
      matchedId: id,
      liveMaxSim: saved3d.liveInfo3d?.liveMaxSim ?? 1.0,
      liveMaxId: id,
      obfMaxSim: null,
      obfMaxId: null,
   };
}

let historyInitialized = false;

function collectCurrentFaceIds() {
   const ids = new Set();
   for (const face of (state.db?.faces || [])) ids.add(face.id);
   for (const face of (state.db3d?.faces || [])) ids.add(face.id);
   return Array.from(ids).sort((a, b) => b - a);
}

function has2dRecord(id) {
   return (state.db?.faces || []).some((face) => face.id === id);
}

function has3dRecord(id) {
   return (state.db3d?.faces || []).some((face) => face.id === id);
}

function getSavedAtForHistory(id) {
   const record2d = (state.db?.faces || []).find((face) => face.id === id);
   if (record2d?.savedAt) return record2d.savedAt;

   const record3d = (state.db3d?.faces || []).find((face) => face.id === id);
   return record3d?.savedAt || null;
}

function formatHistoryTime(savedAt) {
   if (!savedAt) return '--:--';
   const date = new Date(savedAt);
   if (Number.isNaN(date.getTime())) return '--:--';

   const hours = String(date.getHours()).padStart(2, '0');
   const minutes = String(date.getMinutes()).padStart(2, '0');
   return `${hours}:${minutes}`;
}

function createHistoryStatus(label, present) {
   const status = document.createElement('div');
   status.className = 'history-status';

   const dot = document.createElement('span');
   dot.className = `history-dot ${present ? 'present' : 'missing'}`;
   status.appendChild(dot);

   const text = document.createElement('span');
   text.textContent = label;
   status.appendChild(text);

   return status;
}

function removeFaceById(id) {
   state.db.faces = state.db.faces.filter((face) => face.id !== id);
   state.db3d.faces = state.db3d.faces.filter((face) => face.id !== id);

   deleteThumbnail(id);
   persistDb3d();
   persistDb();
   renderDbStats(state, els);
}

function createHistoryCard(id) {
   const card = document.createElement('article');
   card.className = 'history-card';
   card.dataset.id = String(id);
   const savedAt = getSavedAtForHistory(id);

   const dataUrl = getThumbnail(id);
   if (dataUrl) {
      const thumb = document.createElement('img');
      thumb.className = 'history-thumb';
      thumb.alt = t('face_thumbnail_alt', { id });
      thumb.src = dataUrl;
      card.appendChild(thumb);
   } else {
      const placeholder = document.createElement('div');
      placeholder.className = 'history-placeholder';
      placeholder.textContent = t('no_preview');
      card.appendChild(placeholder);
   }

   const idRow = document.createElement('div');
   idRow.className = 'history-id-row';

   const idLabel = document.createElement('div');
   idLabel.className = 'history-id';
   idLabel.textContent = `ID ${id}`;
   idRow.appendChild(idLabel);

   const timeLabel = document.createElement('div');
   timeLabel.className = 'history-time';
   timeLabel.textContent = formatHistoryTime(savedAt);
   if (savedAt) timeLabel.title = savedAt;
   idRow.appendChild(timeLabel);

   card.appendChild(idRow);

   const statusRow = document.createElement('div');
   statusRow.className = 'history-status-row';
   statusRow.appendChild(createHistoryStatus('2D', has2dRecord(id)));
   statusRow.appendChild(createHistoryStatus('3D', has3dRecord(id)));
   card.appendChild(statusRow);

   const actions = document.createElement('div');
   actions.className = 'history-actions';

   const deleteBtn = document.createElement('button');
   deleteBtn.type = 'button';
   deleteBtn.className = 'history-delete';
   deleteBtn.title = t('delete_id_label', { id });
   const deleteIconMarkup = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
   let confirmTimeoutId = null;

   function resetDeleteButton() {
      if (confirmTimeoutId) {
         clearTimeout(confirmTimeoutId);
         confirmTimeoutId = null;
      }
      deleteBtn.classList.remove('confirm-pending');
      deleteBtn.innerHTML = deleteIconMarkup;
      deleteBtn.title = t('delete_id_label', { id });
      deleteBtn.setAttribute('aria-label', t('delete_id_label', { id }));
   }

   deleteBtn.innerHTML = deleteIconMarkup;
   deleteBtn.setAttribute('aria-label', t('delete_id_label', { id }));
   deleteBtn.addEventListener('click', () => {
      if (!deleteBtn.classList.contains('confirm-pending')) {
         deleteBtn.classList.add('confirm-pending');
         deleteBtn.textContent = 'OK';
         deleteBtn.title = t('confirm_delete_id_label', { id });
         deleteBtn.setAttribute('aria-label', t('confirm_delete_id_label', { id }));
         confirmTimeoutId = setTimeout(resetDeleteButton, 2000);
         return;
      }

      resetDeleteButton();
      removeFaceById(id);
   });

   actions.appendChild(deleteBtn);
   card.appendChild(actions);

   return card;
}

function renderHistoryEntries() {
   if (!els.historyEntries) return;

   // TODO: optimize with diff against current visible IDs to avoid full rebuilds.
   els.historyEntries.innerHTML = '';
   const ids = collectCurrentFaceIds();

   if (!ids.length) {
      const empty = document.createElement('p');
      empty.className = 'history-empty';
      empty.textContent = t('no_saved_faces_empty');
      els.historyEntries.appendChild(empty);
      return;
   }

   for (const id of ids) {
      els.historyEntries.appendChild(createHistoryCard(id));
   }
}

async function tryCaptureThumbnailOnSave() {
   const detection = state.lastKnownEffectResult?.detection?.box
      ? { box: state.lastKnownEffectResult.detection.box }
      : await faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS);

   if (!detection?.box) return null;
   return captureThumbnail(els.video, detection.box);
}

/**
 * Initializes the application: loads the database, renders statistics, sets up the canvas,
 * registers UI event listeners, loads models, ghostyle plugins, and starts the webcam.
 * This is the entry point called at the end of the script.
 *
 * @see loadModels – called within init to load face-api.js models before webcam activation.
 * @see init(); – the function is invoked at the bottom of the script to start the app.
 */
async function init() {
   initI18n();
   setupLocaleSelect(els.localeSelect, () => {
      applyI18n();
      renderHistoryEntries();
      state.gstmxxEvents.dispatchEvent(new CustomEvent('dbChanged', {
         detail: { count: state.db.faces.length, nextId: state.db.nextId }
      }));
   });

   state.db = loadDb();
   state.db3d = loadDb3d();
   renderDbStats(state, els);
   resizeCanvas(els);

   if (els.toggleSettingsBtn && els.historyDrawer && els.settingsDrawer) {
      els.toggleSettingsBtn.addEventListener('click', () => {
         if (!els.settingsDrawer.classList.contains('hidden')) {
            closeHistoryDrawer();
         }
      });
   }
   if (els.closeSettingsBtn && els.historyDrawer) {
      els.closeSettingsBtn.addEventListener('click', closeHistoryDrawer);
   }

   if (els.reloadPluginsBtn) {
      if (isLocalPluginDevHost()) {
         els.reloadPluginsBtn.style.display = '';
         els.reloadPluginsBtn.addEventListener('click', async () => {
            setLog(t('plugin_reload_started_log'), 'loader');
            try {
               const loaded = await reloadPlugins({
                  onFaceapiToggle: () => startEffectLoop(state, els)
               });
               setLog(t('plugin_reload_done_log', { count: loaded }), 'loader');
            } catch (err) {
               setLog(t('plugin_reload_error_log', { message: err.message || err }), 'loader');
            }
         });
      } else {
         els.reloadPluginsBtn.style.display = 'none';
      }
   }

   state.gstmxxEvents.addEventListener('dbChanged', renderHistoryEntries);

   const initialOverlayMode = readInitialOverlayMode();
   setOverlayMode(initialOverlayMode);
   if (els.overlayModeBtn) {
      els.overlayModeBtn.textContent = overlayModeLabel(initialOverlayMode);
      els.overlayModeBtn.addEventListener('click', () => {
         const currentMode = els.overlayModeBtn.dataset.overlayMode || initialOverlayMode;
         const keys = Object.keys(OVERLAY_MODES);
         const currentIndex = keys.indexOf(currentMode);
         const nextMode = keys[(currentIndex + 1) % keys.length];
         els.overlayModeBtn.dataset.overlayMode = setOverlayMode(nextMode);
         els.overlayModeBtn.textContent = overlayModeLabel(els.overlayModeBtn.dataset.overlayMode);
      });
      els.overlayModeBtn.dataset.overlayMode = initialOverlayMode;
   }

   window.addEventListener('resize', function () {
      resizeCanvas(els);
   });

   if (els.logBox) {
      els.logBox.addEventListener('click', () => {
         state.isLogExpanded = !state.isLogExpanded;
         updateLogDisplay();
      });
   }

   els.copyMakeupBtn.addEventListener('click', exportMakeup);

   if (els.recordBtn) {
      els.recordBtn.addEventListener('click', recordOneSecond);
   }

   els.saveBtn.addEventListener('click', async () => {
      setBusy(true);
      try {
         let thumbnailDataUrl = null;
         try {
            thumbnailDataUrl = await tryCaptureThumbnailOnSave();
         } catch (thumbErr) {
            setLog(t('thumbnail_capture_failed_log', { message: thumbErr.message || thumbErr }), 'thumbnails');
         }

         // 1. 2D engine: detect + save; returns { id, result } or null
         const saved2d = await saveFace();
         if (!saved2d) return;
         const { id } = saved2d;

         if (thumbnailDataUrl) saveThumbnail(id, thumbnailDataUrl);

         // 2. 3D engine: extract ImageEmbedder embedding + save under the same ID
         const saved3d = await saveFace3d(id);

         // Keep listeners synced with potential 3D-only status changes on the card.
         state.gstmxxEvents.dispatchEvent(new CustomEvent('dbChanged', {
            detail: {
               count: state.db.faces.length,
               nextId: state.db.nextId,
            }
         }));

         // 3. Build unified payload
         const faceapiSection = buildFaceapiSaveSection(saved2d);
         const mediapipeSection = saved3d
            ? buildMediapipeSaveSection(saved3d, id)
            : null;

         state.gstmxxEvents.dispatchEvent(new CustomEvent('matchStateChanged', {
            detail: {
               source: 'save',
               ghostylePresent: false,
               faceapi: faceapiSection,
               mediapipe: mediapipeSection,
               overall: computeOverall(faceapiSection, mediapipeSection),
            }
         }));
      }
      catch (err) { handleError(err, t('save_face_error')); }
      finally {
         setBusy(false);
         if (state.activeEffect) startEffectLoop(state, els);
      }
   });

   els.analyzeBtn.addEventListener('click', async () => {
      setBusy(true);
      try {
         await openAnalyzePanel();
      }
      catch (err) { handleError(err, t('makeup_analysis_error')); }
      finally {
         setBusy(false);
      }
   });

   els.clearDbBtn.addEventListener('click', () => {
      const svgIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
      if (els.clearDbBtn.textContent === t('confirm_question')) {
         clearDb(state, els);
         els.clearDbBtn.innerHTML = svgIcon;
      } else {
         els.clearDbBtn.textContent = t('confirm_question');
         setTimeout(() => {
            if (els.clearDbBtn.textContent === t('confirm_question')) {
               els.clearDbBtn.innerHTML = svgIcon;
               els.clearDbBtn.dataset.i18n = 'delete_all_button';
               applyI18n(els.clearDbBtn.parentElement || document);
            }
         }, 4000);
      }
   });

   setBusy(true);
   setLog(t('loading_face_system_log'))
   try {
      await loadModels();
   } catch (err) {
      setLog(t('loading_error_log', { message: err.message }));
      return;
   }

   setLog(t('loading_embedder_log'));
   try {
      await loadMobileNet();
   } catch (err) {
      setLog(t('embedder_unavailable_log', { message: err.message }));
   }

   setLog(t('loading_makeup_plugins_log'))
   try {
      initPlugins3dLoader({
         getFaceLandmarker: () => (window.gstmxx && window.gstmxx.FaceLandmarker) || null
      });

      /* Unified manifest: all plugins are loaded through ghostyles-manager. */
      const relurl = window.location.pathname.split('/').slice(0, -1).join('/')
      const ghostListUrl = relurl + '/ghostyles.json'
      const ghostylistRes = await fetch(ghostListUrl);
      if (ghostylistRes.ok) {
         const list = await ghostylistRes.json();
         for (const item of list) {
            let effectiveUrl = relurl + '/' + item.url;
            await loadGhostyle(effectiveUrl, item.id || item.name, {
               onFaceapiToggle: () => startEffectLoop(state, els)
            });
         }
      }
      else
         throw new Error(`HTTP ${ghostylistRes.status}`);
   } catch (err) {
      setLog(t('ghostyles_json_error_log', { message: err.message }));
   }

   setLog(t('init_complete_webcam_log'));
   try {
      await startCamera(state, els);
   } catch (err) {
      handleError(err, t('webcam_permission_error', { origin: window.location.origin }));
      return;
   }

   /*
   // DEBUG: intercetta tutti gli errori non catturati nei listener
   window.addEventListener('unhandledrejection', e => {
      console.error('[unhandledrejection]', e.reason);
   });

   // DEBUG: log di tutti gli eventi del bus
   const _origDispatch = state.gstmxxEvents.dispatchEvent.bind(state.gstmxxEvents);
   state.gstmxxEvents.dispatchEvent = function (event) {
      if (!(event.type === "landmarks3d" ||
         event.type === "detection" ||
         event.type === "matchStateChanged"))
         console.debug(`[event:${event.type}]`, event.detail);
      return _origDispatch(event);
   };
   */

   setLog(t('ready_start_log'));
   setBusy(false);
   state.gstmxxEvents.dispatchEvent(new CustomEvent('ready', { detail: {} }));

   // Questo evento segnala ai file con i loop, che l'ambiente è pronto, e troveranno
   // state, els, e window.gstmxx pronti.
   window.dispatchEvent(new CustomEvent('ghostatiReady'));
}

if (typeof process === 'undefined' || !process.env.VITEST) {
   init();
}