scripts/face-thumbnails.jsjavascript
/** @module face-thumbnails */
import {
   THUMBNAIL_JPEG_QUALITY,
   THUMBNAIL_MARGIN_RATIO,
   THUMBNAIL_MAX_ENTRIES,
   THUMBNAIL_OUTPUT_SIZE,
} from './config.js';
import { setLog } from './utils.js';
import { t } from './i18n.js';

export const THUMBNAILS_STORAGE_KEY = 'local-face-lab-thumbnails-v1';

/**
 * Creates the empty thumbnail-store shape used when localStorage has no saved
 * face thumbnails, contains malformed data, or is being reset with the database.
 *
 * @returns {{entries: Array<object>, maxEntries: number}} Empty thumbnail store with configured capacity.
 * @see loadThumbnailsStore - Falls back to this shape when storage cannot be read.
 * @see clearAllThumbnails - Persists this shape when the local face archive is cleared.
 */
function defaultStore() {
   return {
      entries: [],
      maxEntries: THUMBNAIL_MAX_ENTRIES,
   };
}

/**
 * Writes the normalized face-thumbnail store to localStorage under the module's
 * dedicated key, keeping images separate from the 2D and 3D biometric databases.
 *
 * @param {{entries: Array<object>, maxEntries: number}} store - Store object to serialize.
 * @returns {void}
 * @see saveThumbnail - Persists after adding or evicting a thumbnail.
 * @see deleteThumbnail - Persists after removing one face thumbnail.
 * @see clearAllThumbnails - Persists the empty thumbnail store.
 */
function persistStore(store) {
   localStorage.setItem(THUMBNAILS_STORAGE_KEY, JSON.stringify(store));
}

/**
 * Converts face IDs from UI, database, or test inputs into the numeric key used
 * by the thumbnail store.
 *
 * @param {number|string} id - Face id to normalize.
 * @returns {number} Numeric id, possibly `NaN` for invalid input.
 * @see saveThumbnail - Normalizes the saved face id before storage.
 * @see deleteThumbnail - Normalizes the target id before removal.
 * @see getThumbnail - Normalizes the lookup id before reading.
 */
function normalizeId(id) {
   return typeof id === 'number' ? id : Number(id);
}

/**
 * Logs thumbnail storage events through the plugin-facing logger when available,
 * falling back to the app logger during tests or early module use. In practice
 * this reports FIFO evictions when the local thumbnail cache reaches capacity.
 *
 * @param {string} message - Message to write under the `thumbnails` source.
 * @returns {void}
 * @see saveThumbnail - Reports evicted thumbnails when capacity is exceeded.
 */
function logThumbnailEvent(message) {
   if (window.gstmxx && typeof window.gstmxx.log === 'function') {
      window.gstmxx.log(message, 'thumbnails');
      return;
   }
   setLog(message, 'thumbnails');
}

/**
 * Repairs parsed localStorage data into the current thumbnail-store contract.
 * Invalid records are dropped, IDs are coerced to numbers, and capacity falls
 * back to the configured default when older or corrupted data omits it.
 *
 * @param {*} parsed - Parsed JSON value read from localStorage.
 * @returns {{entries: Array<{id: number, dataUrl: string, savedAt: string}>, maxEntries: number}} Normalized thumbnail store.
 * @see loadThumbnailsStore - Applies this after parsing persisted thumbnail data.
 */
function normalizeStoreShape(parsed) {
   if (!parsed || !Array.isArray(parsed.entries)) return defaultStore();

   const maxEntries = Number.isFinite(parsed.maxEntries) && parsed.maxEntries > 0
      ? Math.floor(parsed.maxEntries)
      : THUMBNAIL_MAX_ENTRIES;

   const entries = parsed.entries
      .filter((entry) => entry && Number.isFinite(Number(entry.id)) && typeof entry.dataUrl === 'string' && typeof entry.savedAt === 'string')
      .map((entry) => ({
         id: Number(entry.id),
         dataUrl: entry.dataUrl,
         savedAt: entry.savedAt,
      }));

   return { entries, maxEntries };
}

/**
 * Moves a crop rectangle back inside the video frame without changing its size
 * unless the requested crop is larger than the frame. This keeps edge-face
 * thumbnails from sampling outside the webcam image.
 *
 * @param {{x: number, y: number, width: number, height: number}} rect - Desired crop rectangle with margin applied.
 * @param {number} frameWidth - Source video frame width.
 * @param {number} frameHeight - Source video frame height.
 * @returns {{x: number, y: number, width: number, height: number}} Crop rectangle safe for `drawImage`.
 * @see captureThumbnail - Uses this before drawing the face crop into the square thumbnail canvas.
 */
function shiftRectInsideBounds(rect, frameWidth, frameHeight) {
   const shifted = { ...rect };

   if (shifted.width > frameWidth) {
      shifted.x = 0;
      shifted.width = frameWidth;
   } else {
      if (shifted.x < 0) shifted.x = 0;
      if (shifted.x + shifted.width > frameWidth) shifted.x = frameWidth - shifted.width;
   }

   if (shifted.height > frameHeight) {
      shifted.y = 0;
      shifted.height = frameHeight;
   } else {
      if (shifted.y < 0) shifted.y = 0;
      if (shifted.y + shifted.height > frameHeight) shifted.y = frameHeight - shifted.height;
   }

   return shifted;
}

/**
 * Captures a square JPEG thumbnail around a detected face box from the live
 * webcam frame. The crop includes configurable margin, is clamped to frame
 * bounds, letterboxes with black when needed, and is used as the small identity
 * preview in history and analysis comparisons.
 *
 * @param {HTMLVideoElement} videoEl - Live webcam video element to sample from.
 * @param {{x: number, y: number, width: number, height: number}} box - face-api detection box in video pixels.
 * @param {object} [options={}] - Optional thumbnail rendering overrides.
 * @param {number} [options.marginRatio] - Extra crop margin as a ratio of the face box short side.
 * @param {number} [options.outputSize] - Square output canvas size in pixels.
 * @param {number} [options.jpegQuality] - JPEG quality passed to `toDataURL`.
 * @returns {Promise<string>} JPEG data URL for storage or comparison.
 * @throws {Error} When the video element, face box, video frame, or canvas context is unavailable.
 * @see tryCaptureThumbnailOnSave - Captures the saved-face thumbnail during the Save workflow.
 * @see buildVisualComparison - Captures the current face thumbnail for the analysis panel.
 */
export async function captureThumbnail(videoEl, box, options = {}) {
   const marginRatio = Number.isFinite(options.marginRatio) ? options.marginRatio : THUMBNAIL_MARGIN_RATIO;
   const outputSize = Number.isFinite(options.outputSize) ? options.outputSize : THUMBNAIL_OUTPUT_SIZE;
   const jpegQuality = Number.isFinite(options.jpegQuality) ? options.jpegQuality : THUMBNAIL_JPEG_QUALITY;

   if (!videoEl) throw new Error(t('capture_thumbnail_video_required_error'));
   if (!box || !Number.isFinite(box.x) || !Number.isFinite(box.y) || !Number.isFinite(box.width) || !Number.isFinite(box.height)) {
      throw new Error(t('capture_thumbnail_invalid_box_error'));
   }

   const frameWidth = Number(videoEl.videoWidth || videoEl.clientWidth || 0);
   const frameHeight = Number(videoEl.videoHeight || videoEl.clientHeight || 0);
   if (!frameWidth || !frameHeight) {
      throw new Error(t('capture_thumbnail_frame_not_ready_error'));
   }

   const shortSide = Math.min(box.width, box.height);
   const margin = shortSide * Math.max(0, marginRatio);

   const rawRect = {
      x: box.x - margin,
      y: box.y - margin,
      width: box.width + margin * 2,
      height: box.height + margin * 2,
   };

   const cropRect = shiftRectInsideBounds(rawRect, frameWidth, frameHeight);

   const canvas = document.createElement('canvas');
   canvas.width = outputSize;
   canvas.height = outputSize;
   const ctx = canvas.getContext('2d');
   if (!ctx) throw new Error(t('capture_thumbnail_context_error'));

   const scale = Math.min(outputSize / cropRect.width, outputSize / cropRect.height);
   const destWidth = cropRect.width * scale;
   const destHeight = cropRect.height * scale;
   const destX = (outputSize - destWidth) / 2;
   const destY = (outputSize - destHeight) / 2;

   ctx.fillStyle = 'rgba(0, 0, 0, 1)';
   ctx.fillRect(0, 0, outputSize, outputSize);
   ctx.drawImage(
      videoEl,
      cropRect.x,
      cropRect.y,
      cropRect.width,
      cropRect.height,
      destX,
      destY,
      destWidth,
      destHeight,
   );

   return canvas.toDataURL('image/jpeg', jpegQuality);
}

/**
 * Loads the persisted face-thumbnail store from localStorage, tolerating missing
 * entries, malformed JSON, and stale shapes so the history drawer never fails
 * because thumbnail metadata is corrupt.
 *
 * @returns {{entries: Array<{id: number, dataUrl: string, savedAt: string}>, maxEntries: number}} Normalized thumbnail store.
 * @see saveThumbnail - Reads the current store before adding an entry.
 * @see deleteThumbnail - Reads the current store before filtering an entry.
 * @see getThumbnail - Reads the current store before lookup.
 */
export function loadThumbnailsStore() {
   try {
      const raw = localStorage.getItem(THUMBNAILS_STORAGE_KEY);
      if (!raw) return defaultStore();
      const parsed = JSON.parse(raw);
      return normalizeStoreShape(parsed);
   } catch {
      return defaultStore();
   }
}

/**
 * Saves or replaces the thumbnail for a face id and enforces the configured
 * FIFO capacity. When capacity is exceeded, the oldest `savedAt` entries are
 * evicted and a thumbnail log message is emitted.
 *
 * @param {number|string} id - Saved face id associated with the thumbnail.
 * @param {string} dataUrl - JPEG data URL produced by `captureThumbnail`.
 * @returns {void}
 * @see init - Calls this after `saveFace` succeeds in the Save workflow.
 * @see logThumbnailEvent - Reports FIFO eviction messages.
 */
export function saveThumbnail(id, dataUrl) {
   if (typeof dataUrl !== 'string' || !dataUrl) return;

   const normalizedId = normalizeId(id);
   if (!Number.isFinite(normalizedId)) return;

   const store = loadThumbnailsStore();
   const savedAt = new Date().toISOString();

   store.entries = store.entries.filter((entry) => entry.id !== normalizedId);
   store.entries.push({ id: normalizedId, dataUrl, savedAt });

   while (store.entries.length > store.maxEntries) {
      store.entries.sort((a, b) => new Date(a.savedAt).getTime() - new Date(b.savedAt).getTime());
      const evicted = store.entries.shift();
      if (evicted) {
         logThumbnailEvent(`Thumbnail evicted for ID ${evicted.id} (FIFO capacity ${store.maxEntries}).`);
      }
   }

   persistStore(store);
}

/**
 * Deletes the thumbnail associated with a face id, used when a history card
 * removes the corresponding 2D/3D face records.
 *
 * @param {number|string} id - Face id whose thumbnail should be removed.
 * @returns {void}
 * @see removeFaceById - Removes thumbnail data when deleting a single saved face.
 */
export function deleteThumbnail(id) {
   const normalizedId = normalizeId(id);
   if (!Number.isFinite(normalizedId)) return;

   const store = loadThumbnailsStore();
   store.entries = store.entries.filter((entry) => entry.id !== normalizedId);
   persistStore(store);
}

/**
 * Retrieves the stored thumbnail data URL for a saved face id. Missing or
 * invalid ids return null so history and analysis UI can fall back gracefully.
 *
 * @param {number|string} id - Face id to look up.
 * @returns {string|null} Stored JPEG data URL, or null when no thumbnail exists.
 * @see createHistoryCard - Displays saved thumbnails in the history drawer.
 * @see buildVisualComparison - Retrieves the baseline thumbnail for analysis comparisons.
 */
export function getThumbnail(id) {
   const normalizedId = normalizeId(id);
   if (!Number.isFinite(normalizedId)) return null;

   const store = loadThumbnailsStore();
   const entry = store.entries.find((item) => item.id === normalizedId);
   return entry ? entry.dataUrl : null;
}

/**
 * Clears all saved face thumbnails while preserving the thumbnail-store schema.
 * This keeps the thumbnail cache aligned with the local 2D and 3D biometric
 * databases when the user clears the archive.
 *
 * @returns {void}
 * @see clearDb - Calls this when wiping the local biometric archive.
 */
export function clearAllThumbnails() {
   persistStore(defaultStore());
}