// ==UserScript== // @name 百分网自动刷课助手 // @description 自动完成百分网人脸验证与课程播放,支持防切屏检测与人脸图片管理 // @namespace https://greasyfork.org/baifenwang-auto-study // @version 1.0.0 // @author TheSkyC // @license MIT // @homepageURL https://github.com/TheSkyC/baifenwang-auto-study // @supportURL https://github.com/TheSkyC/baifenwang-auto-study/issues // @match *://*.tj.100.wang/* // @run-at document-start // @compatible Tampermonkey // @compatible Greasemonkey // @compatible Violentmonkey // @compatible ScriptCat // @compatible AdGuard // @grant none // ==/UserScript== (function () { 'use strict'; /** * @file Image pool and face detection configuration. * @module config/pool */ // Image pool configuration const IMAGE_POOL_CONFIG = { /** Maximum number of images the user can store */ MAX_IMAGES: 50, // ---- Standard output ---- /** Output width (all stored images are forced to this) */ OUTPUT_WIDTH: 400, /** Output height (all stored images are forced to this) */ OUTPUT_HEIGHT: 300, /** Maximum pixel dimension (width or height) for stored images — keeps aspect ratio */ MAX_DIMENSION: 800, /** JPEG export quality (0.0 – 1.0) for storage */ JPEG_QUALITY: 0.78, // ---- Original image compression (kept for crop editing) ---- /** Max pixel dimension for stored originals — keeps file size under quota */ ORIG_MAX_DIMENSION: 1200, /** JPEG quality for stored originals (aggressive to save quota) */ ORIG_JPEG_QUALITY: 0.65, // ---- Upload guards ---- /** Accepted MIME types for upload */ ACCEPTED_TYPES: ['image/jpeg', 'image/png', 'image/webp', 'image/bmp'], /** Maximum file size before compression (bytes) — reject larger files upfront */ MAX_FILE_SIZE: 10 * 1024 * 1024, // 10 MB // ---- Dedup ---- /** dHash Hamming-distance threshold: ≤ this value → considered duplicate */ DEDUP_HAMMING_THRESHOLD: 8, // ---- Mutation (applied at pick-time, never alters stored originals) ---- /** Probability that mutation is enabled at all per pick */ MUTATION_ENABLED: true, /** Per-mutation activation chances (0–1). ~2–5 mutations fire per pick. */ MUTATION_CHANCE_BRIGHTNESS: 0.40, MUTATION_CHANCE_CONTRAST: 0.35, MUTATION_CHANCE_SATURATION: 0.30, MUTATION_CHANCE_HUE: 0.15, MUTATION_CHANCE_FLIP: 0, MUTATION_CHANCE_ROTATE: 0.45, MUTATION_CHANCE_SCALE_JITTER: 0.30, /** Ranges */ MUTATION_BRIGHTNESS_RANGE: [0.85, 1.15], // multiplier MUTATION_CONTRAST_RANGE: [0.88, 1.12], MUTATION_SATURATION_RANGE: [0.85, 1.15], MUTATION_HUE_RANGE: [-4, 4], // degrees MUTATION_ROTATE_RANGE: [-2.5, 2.5], // degrees MUTATION_SCALE_RANGE: [1.0, 1.06], // multiplier per axis (floor ≥ 1.0 prevents black borders) /** JPEG quality range for mutated output */ MUTATION_QUALITY_RANGE: [0.72, 0.85], // ---- Quality scoring (weighted random selection) ---- /** * Per-image usage stats are persisted separately (bfw_img_stats) and * drive a weighted random selection algorithm. High-quality images * get boosted probability; low-quality images are down-weighted but * never fully excluded — every image always has a non-zero chance. */ QUALITY_SCORING: { /** Minimum uses before quality tier can drop below neutral */ MIN_USES_FOR_ASSESSMENT: 3, /** Minimum failure count before an image can be classified as low-quality */ LOW_QUALITY_FAILURE_THRESHOLD: 3, /** Failure rate (failures / totalUses) at or above this value → low quality */ LOW_QUALITY_FAIL_RATE: 0.5, /** Success rate at or above this → high quality (requires enough uses) */ HIGH_QUALITY_SUCCESS_RATE: 0.7, /** Minimum uses before an image can be classified as high-quality */ HIGH_QUALITY_MIN_USES: 5, /** Weight multiplier for low-quality images (0.15 = 15% of neutral) */ LOW_QUALITY_WEIGHT: 0.15, /** Weight multiplier for neutral / new images (baseline) */ NEUTRAL_WEIGHT: 1.0, /** Weight multiplier for high-quality images */ HIGH_QUALITY_WEIGHT: 2.5, }, // ---- Storage ---- /** Storage key prefix (shared across all storage backends) */ STORAGE_KEY_PREFIX: 'bfw_img_', /** Metadata key */ META_KEY: 'bfw_meta', /** Stats key for per-image quality tracking */ STATS_KEY: 'bfw_img_stats', }; // Face detection (smart crop) configuration const FACE_DETECT_CONFIG = { // ---- Tier 1: Native FaceDetector API ---- /** Whether to attempt the browser-native FaceDetector API at all */ NATIVE_ENABLED: true, /** Maximum time (ms) to wait for FaceDetector before falling back */ DETECT_TIMEOUT_MS: 2000, /** Maximum number of faces to request from FaceDetector */ MAX_FACES: 5, /** Prefer fast/low-accuracy mode for FaceDetector */ FAST_MODE: true, // ---- Tier 2: Skin-color heuristic ---- /** Downsample size for skin-color analysis (pixels on longest side) */ SKIN_SAMPLE_SIZE: 80, /** Minimum skin-pixel count for heuristic to be considered valid */ SKIN_MIN_PIXELS: 50, /** Grid dimensions for skin-pixel clustering (cols × rows) */ SKIN_GRID_COLS: 4, SKIN_GRID_ROWS: 3, /** YCbCr skin-pixel thresholds (ITU-R BT.601, illumination-invariant) */ SKIN_CB_MIN: 77, SKIN_CB_MAX: 127, SKIN_CR_MIN: 133, SKIN_CR_MAX: 173, // ---- Tier 3: Fixed-bias fallback ---- /** * Vertical bias when no faces are detected by either tier. * Same value as IMAGE_POOL_CONFIG.CROP_FACE_BIAS — kept here as the * canonical source for the face-detection pipeline. */ CROP_FALLBACK_BIAS: 0.38, // ---- Detection quality filters ---- /** Minimum face bounding-box area as fraction of total image area */ MIN_FACE_AREA_RATIO: 0.005, /** Minimum face bounding-box dimension in pixels (reject spurious tiny detections) */ MIN_FACE_SIZE_PX: 10, }; // Crop editor configuration const CROP_EDITOR_CONFIG = { /** Maximum displayed width/height in the editor (px) — image is scaled to fit */ MAX_DISPLAY_SIZE: 480, /** Handle radius for interaction hit-testing (px) */ HANDLE_RADIUS: 10, /** Handle visual size in CSS (px) */ HANDLE_SIZE: 12, /** Minimum crop rectangle size in source pixels */ MIN_CROP_PX: 20, /** Target aspect ratio (width / height) */ TARGET_RATIO: 4 / 3, /** Live preview thumbnail size (px, square bounding box) */ PREVIEW_SIZE: 72, }; /** * @file Media configuration — video stream replacement, frame capture, and overlay. * @module config/media */ // Video stream replacement configuration const VIDEO_REPLACE_CONFIG = { /** Default canvas width when constraints don't specify */ DEFAULT_WIDTH: 640, /** Default canvas height when constraints don't specify */ DEFAULT_HEIGHT: 480, /** Canvas stream FPS */ STREAM_FPS: 30, /** Subtle brightness jitter range (±this fraction) to simulate live camera */ BRIGHTNESS_JITTER: 0.02, }; // Video frame capture selectors (for manual "capture" button) const VIDEO_CAPTURE_SELECTORS = ['#video', '.main_content', 'video[autoplay]', 'video']; /** * @file Core configuration constants for baifenwang-auto-study. * * Domain-specific configs are in config/pool.js and config/media.js. */ // Script metadata const SCRIPT_NAME = '百分网自动刷课助手'; const SCRIPT_VERSION = '1.0.0'; // Log level const LOG_LEVEL = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, }; // Current log level (change to LOG_LEVEL.DEBUG for verbose output) const CURRENT_LOG_LEVEL = LOG_LEVEL.INFO; // Settings storage key (shared across GM, localStorage, and in-memory backends) const SETTINGS_KEY = 'bfw_settings'; // Retry settings for auto-processor const AUTO_CONFIG = { /** Delay before initial sequence kickoff (ms) */ CLICK_DELAY_MS: 300, /** Delay after clicking "open camera" before trying the photo button (ms) */ CAMERA_OPEN_DELAY_MS: 1500, /** Delay after clicking "take photo" before trying the compare button (ms) */ PHOTO_DELAY_MS: 1000, /** Max retries when compare button is not yet in the DOM */ MAX_COMPARE_RETRIES: 5, /** Retry interval for compare button polling (ms) */ COMPARE_RETRY_DELAY_MS: 400, /** Max consecutive retry-button cycles before giving up */ RETRY_MAX_ATTEMPTS: 5, /** Base delay for exponential backoff on retry (ms) */ RETRY_BASE_DELAY_MS: 2000, /** Maximum backoff delay cap (ms) */ RETRY_MAX_DELAY_MS: 30000, /** Minimum wait after clicking "开始对比" before handleCompareFailRecovery * may fire. The server needs time to respond; without this cooldown any * DOM mutation during the server round-trip is misidentified as a failure * because the page still shows both "重新拍照" and "开始对比" buttons. */ COMPARE_COOLDOWN_MS: 4000, /** Delay after clicking retry button before camera-open click (ms). * Used by onRetry() as a bridge between retry and the normal pipeline. */ RETRY_CAMERA_DELAY_MS: 800, }; // Auto-course (自动刷课) configuration const COURSE_CONFIG = { /** Delay before clicking the play button after page load (ms) */ PLAY_CLICK_DELAY_MS: 1000, /** Max retries for finding and clicking the play button */ PLAY_MAX_RETRIES: 10, /** Retry interval for play button polling (ms) */ PLAY_RETRY_DELAY_MS: 500, /** How often to update the progress display (ms) */ PROGRESS_UPDATE_INTERVAL_MS: 3000, /** Seconds of paused playback before auto-resuming */ STUCK_THRESHOLD_S: 30, /** Max auto-resume attempts before giving up */ MAX_RESUME_ATTEMPTS: 3}; // --------------------------------------------------------------------------- // Delay jitter — prevents bot-detection via fixed-interval timing analysis. // Human reaction times have a coefficient of variation (CV) in the 0.3–0.8 // range; fixed delays are CV=0. Apply jitter at every usage site so the // timing fingerprint becomes unpredictable. // --------------------------------------------------------------------------- /** * Apply symmetric random jitter to a base delay. * Returns `baseMs * (1 ± factor)` — the delay can be SHORTER or LONGER. * Suitable for click delays, polling intervals, and general wait times. * * @param {number} baseMs - base delay in milliseconds * @param {number} [factor=0.3] - jitter range (0.3 = ±30%) * @returns {number} jittered delay (always ≥ 1ms) */ function jitterMs(baseMs, factor = 0.3) { const f = Math.min(Math.max(factor, 0), 1); return Math.max(1, Math.round(baseMs * (1 + (Math.random() - 0.5) * 2 * f))); } /** * Apply asymmetric jitter — delay is ONLY lengthened, never shortened. * Suitable for cooldowns, thresholds, and minimum-wait guards where * going below the base value risks a false positive (e.g. mistaking * a slow server response for a comparison failure). * * @param {number} baseMs - floor delay in milliseconds * @param {number} [factor=0.3] - maximum increase relative to baseMs * @returns {number} jittered delay in [baseMs, baseMs × (1 + factor)] */ function jitterMsFloor(baseMs, factor = 0.3) { const f = Math.min(Math.max(factor, 0), 1); return Math.round(baseMs * (1 + Math.random() * f)); } /** * @file Logger utility with colored console output * Provides leveled logging with script prefix and optional styles. */ const STYLES$1 = { debug: 'color: #888; font-style: italic;', info: 'color: #2196F3; font-weight: bold;', warn: 'color: #FF9800; font-weight: bold;', error: 'color: #F44336; font-weight: bold;', }; const LABELS = { debug: 'DBG', info: 'INF', warn: 'WRN', error: 'ERR', }; function shouldLog(level) { return level >= CURRENT_LOG_LEVEL; } function formatPrefix(level) { return `%c[${SCRIPT_NAME}][${LABELS[level]}]`; } /** * Log a debug-level message (verbose, hidden by default). * @param {...any} args - Values to log */ function debug(...args) { if (!shouldLog(LOG_LEVEL.DEBUG)) return; console.log(formatPrefix(LOG_LEVEL.DEBUG), STYLES$1.debug, ...args); } /** * Log an info-level message. * @param {...any} args - Values to log */ function info(...args) { if (!shouldLog(LOG_LEVEL.INFO)) return; console.log(formatPrefix(LOG_LEVEL.INFO), STYLES$1.info, ...args); } /** * Log a warning-level message. * @param {...any} args - Values to log */ function warn(...args) { if (!shouldLog(LOG_LEVEL.WARN)) return; console.warn(formatPrefix(LOG_LEVEL.WARN), STYLES$1.warn, ...args); } /** * Log an error-level message. * @param {...any} args - Values to log */ function error(...args) { if (!shouldLog(LOG_LEVEL.ERROR)) return; console.error(formatPrefix(LOG_LEVEL.ERROR), STYLES$1.error, ...args); } /** * @file Storage adapter — unified multi-backend persistent storage. * * Backends (tried in order): * 1. localStorage (per-origin, persistent, ~5 MB) * 2. In-memory Map (session-only fallback) * * All public methods return Promises so callers never need to distinguish * between sync and async backends. * * Note: GM sandbox storage (GM_setValue / GM.getValue) is intentionally NOT * used because it would require @grant annotations that sandbox the script * away from page globals — every DOM / navigator access would then need * unsafeWindow.*, which is a disproportionate refactor for the modest quota * gain. localStorage is reliable and sufficient for a compressed face-image * pool. * * @module utils/storage-adapter */ // --------------------------------------------------------------------------- // Adapter type // --------------------------------------------------------------------------- /** * @typedef {Object} StorageAdapter * @property {(key: string) => Promise} get * @property {(key: string, value: string) => Promise} set * @property {(key: string) => Promise} remove * @property {() => Promise} keys */ // --------------------------------------------------------------------------- // Cached singleton // --------------------------------------------------------------------------- /** @type {StorageAdapter|null} */ let _adapter = null; // --------------------------------------------------------------------------- // Backend detectors // --------------------------------------------------------------------------- /** * Try localStorage (per-origin, persistent). * @returns {StorageAdapter|null} */ function detectLocalStorage() { try { const testKey = '__bfw_storage_test__'; localStorage.setItem(testKey, '1'); localStorage.removeItem(testKey); return { get: (key) => Promise.resolve(localStorage.getItem(key)), set: (key, value) => { localStorage.setItem(key, value); return Promise.resolve(); }, remove: (key) => { localStorage.removeItem(key); return Promise.resolve(); }, keys: () => { const out = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k) out.push(k); } return Promise.resolve(out); }, }; } catch (_) { /* unavailable (private browsing, quota, etc.) */ } return null; } /** * Build an in-memory fallback adapter (session-only, always available). * @returns {StorageAdapter} */ function createMemoryAdapter() { const store = new Map(); return { get: (key) => Promise.resolve(store.get(key) ?? null), set: (key, value) => { store.set(key, value); return Promise.resolve(); }, remove: (key) => { store.delete(key); return Promise.resolve(); }, keys: () => Promise.resolve(Array.from(store.keys())), }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Return the best available storage adapter (cached after first call). * * Detection order: localStorage → in-memory. * * @returns {StorageAdapter} */ function getStorageAdapter() { if (_adapter) return _adapter; // 1. localStorage _adapter = detectLocalStorage(); if (_adapter) { debug('Storage: using localStorage adapter'); return _adapter; } // 2. In-memory fallback warn('Storage: no persistent backend available, using in-memory (session-only)'); _adapter = createMemoryAdapter(); return _adapter; } /** * @file Settings state — centralized toggleable configuration for the userscript. * * Settings are persisted across page reloads via utils/storage-adapter.js. * On load, persisted values are merged over defaults; on every change the * full state is flushed to storage. * * Reserved for future expansion: auto-course (自动刷课) settings group. */ // --------------------------------------------------------------------------- // Defaults — the source of truth for all setting keys and their initial values // --------------------------------------------------------------------------- const DEFAULTS = { /** Enable auto-click for face verification UI elements */ faceAutoClick: true, /** Replace the camera video stream with a pool image (getUserMedia interception) */ videoReplace: true, /** Auto-compare after photo — when OFF, pauses after photo for manual confirmation */ autoCompare: true, // ---- Auto-course (自动刷课) ---- /** Enable auto-course processor — auto-plays course videos and monitors progress */ autoCourse: false, /** Prevent the site from detecting tab-switch / window minimization */ disableVisibilityCheck: false, // ---- Image pool ---- /** Enable weighted random selection based on per-image quality stats */ dynamicWeight: true, }; // --------------------------------------------------------------------------- // State & listeners // --------------------------------------------------------------------------- /** @type {{ [key: string]: Array<(val: any) => void> }} */ const listeners = {}; /** Runtime state (defaults merged with persisted values after init). */ const state = { ...DEFAULTS }; /** Whether the persisted state has been loaded. */ let loaded = false; /** * Load persisted settings from storage and merge over defaults. * Called once during initialization; safe to call multiple times (idempotent). */ async function loadSettings() { if (loaded) return; try { const raw = await getStorageAdapter().get(SETTINGS_KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { // Only merge keys that exist in DEFAULTS — ignore unknown/stale keys for (const key of Object.keys(DEFAULTS)) { if (key in parsed && typeof parsed[key] === typeof DEFAULTS[key]) { state[key] = parsed[key]; } } debug('Settings: loaded from storage'); } } } catch (e) { warn('Settings: failed to load from storage, using defaults:', e); } loaded = true; info(`Settings ready: faceAutoClick=${state.faceAutoClick}, videoReplace=${state.videoReplace}, autoCompare=${state.autoCompare}, autoCourse=${state.autoCourse}, disableVisibilityCheck=${state.disableVisibilityCheck}, dynamicWeight=${state.dynamicWeight}`); } /** * Persist the current state to storage (best-effort). */ async function saveSettings() { try { await getStorageAdapter().set(SETTINGS_KEY, JSON.stringify(state)); } catch (e) { warn('Settings: failed to persist:', e); } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Get the value of a setting. * * When settings have been loaded (the normal case after DOMContentLoaded) * the in-memory state is returned. Before that — during the document-start * window when interceptors are already installed but loadSettings() hasn't * run yet — a synchronous localStorage read is attempted so that persisted * preferences are honoured from the very first intercepted call. * * @template T * @param {string} key * @param {T} [fallback] * @returns {T} */ function getSetting(key, fallback) { // Fast path: settings already loaded if (loaded) { return key in state ? state[key] : fallback; } // Pre-init path: try a synchronous localStorage peek so interceptors // that fire before DOMContentLoaded (e.g. video-interceptor) see the // user's actual preference instead of the hard-coded default. try { const raw = localStorage.getItem(SETTINGS_KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && key in parsed && typeof parsed[key] === typeof DEFAULTS[key]) { return parsed[key]; } } } catch (_) { /* localStorage unavailable or corrupt — use fallback */ } return fallback; } /** * Set a setting value, persist it, and notify listeners. * @param {string} key * @param {*} value */ function setSetting(key, value) { const old = state[key]; if (old === value) return; state[key] = value; // Fire-and-forget persistence — never block the UI saveSettings(); if (listeners[key]) { listeners[key].forEach((fn) => { try { fn(value); } catch (_) { /* noop */ } }); } } /** * Register a listener for changes to a setting. * @param {string} key * @param {(val: any) => void} fn * @returns {() => void} Unsubscribe function */ function onChange(key, fn) { if (!listeners[key]) listeners[key] = []; listeners[key].push(fn); return () => { listeners[key] = listeners[key].filter((f) => f !== fn); }; } /** * Check whether face auto-click is enabled. * Convenience aggregator — used by the auto-processor to decide whether to run. * @returns {boolean} */ function isFaceAutoActive() { return state.faceAutoClick; } /** * @file Face-detection-aware smart cropping. * * Three-tier pipeline for positioning the crop window: * * Tier 1: Browser-native FaceDetector API (Chrome/Edge, secure context) * → area-weighted centroid of all detected faces * * Tier 2: Skin-color heuristic (YCbCr on downscaled canvas) * → density-cluster centroid of skin-tone pixels * * Tier 3: Fixed vertical bias (CROP_FALLBACK_BIAS = 0.38) * → identical to the previous hard-coded behaviour * * Each tier falls through silently on failure so the pipeline degrades * gracefully on any browser. */ // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Smart-crop an image to the standard output dimensions (400×300). * * Attempts face detection to position the crop window around the most * visually important region. Falls back to a fixed vertical bias when * no faces or skin regions can be found. * * @param {HTMLImageElement} img - decoded image (onload already fired) * @returns {Promise<{ dataUrl: string, width: number, height: number }>} */ async function smartCropToStandard(img) { const srcW = img.naturalWidth; const srcH = img.naturalHeight; // Run the face-detection pipeline const faces = await detectFaces(img); let attentionX, attentionY, cropBias; if (faces && faces.length > 0) { // Area-weighted centroid of all detected faces let totalWeight = 0; attentionX = 0; attentionY = 0; for (const f of faces) { const area = f.width * f.height; attentionX += (f.x + f.width / 2) * area; attentionY += (f.y + f.height / 2) * area; totalWeight += area; } attentionX /= totalWeight; attentionY /= totalWeight; // Faces detected → center them in the output (0.6) cropBias = 0.60; debug(`Smart crop: ${faces.length} face(s) detected, attention at (${attentionX.toFixed(0)}, ${attentionY.toFixed(0)})`); } else { // Tier 3 fallback: no faces — use heuristic upper bias attentionX = srcW / 2; attentionY = srcH * FACE_DETECT_CONFIG.CROP_FALLBACK_BIAS; cropBias = FACE_DETECT_CONFIG.CROP_FALLBACK_BIAS; debug('Smart crop: no faces detected, using fixed bias fallback'); } const cropRect = computeCropRect(srcW, srcH, attentionX, attentionY, cropBias); const result = renderCrop(img, cropRect); return { ...result, cropRect }; } // --------------------------------------------------------------------------- // Detection orchestrator // --------------------------------------------------------------------------- /** * Run the three-tier detection pipeline. * * @param {HTMLImageElement} img * @returns {Promise|null>} */ async function detectFaces(img) { // Tier 1: Native FaceDetector API { const nativeFaces = await detectFacesNative(img); if (nativeFaces && nativeFaces.length > 0) return nativeFaces; } // Tier 2: Skin-color heuristic const skinFaces = detectFacesSkinHeuristic(img); if (skinFaces && skinFaces.length > 0) return skinFaces; // Tier 3: nothing found — caller uses fixed bias return null; } // --------------------------------------------------------------------------- // Tier 1: Browser-native FaceDetector // --------------------------------------------------------------------------- /** * Detect faces using the Shape Detection API (FaceDetector). * Available in Chrome/Edge when window.isSecureContext is true. * * @param {HTMLImageElement} img * @returns {Promise|null>} */ async function detectFacesNative(img) { // Secure-context gate — FaceDetector throws on HTTP pages if (typeof window !== 'undefined' && !window.isSecureContext) { debug('FaceDetector: skipping (not a secure context)'); return null; } // API availability gate if (typeof FaceDetector === 'undefined') { debug('FaceDetector: API not available in this browser'); return null; } try { const detector = new FaceDetector({ maxDetectedFaces: FACE_DETECT_CONFIG.MAX_FACES, fastMode: FACE_DETECT_CONFIG.FAST_MODE, }); // Race against timeout to avoid hanging on problematic images const faces = await Promise.race([ detector.detect(img), new Promise((_, reject) => setTimeout(() => reject(new Error('FaceDetector timeout')), FACE_DETECT_CONFIG.DETECT_TIMEOUT_MS), ), ]); if (!Array.isArray(faces) || faces.length === 0) { debug('FaceDetector: no faces found'); return null; } // Filter out spurious tiny detections const srcArea = img.naturalWidth * img.naturalHeight; const minArea = srcArea * FACE_DETECT_CONFIG.MIN_FACE_AREA_RATIO; const minSize = FACE_DETECT_CONFIG.MIN_FACE_SIZE_PX; const filtered = faces .map((f) => ({ x: f.boundingBox.x, y: f.boundingBox.y, width: f.boundingBox.width, height: f.boundingBox.height, })) .filter((f) => f.width >= minSize && f.height >= minSize && f.width * f.height >= minArea, ); if (filtered.length === 0) { debug('FaceDetector: all detections below minimum size, filtered out'); return null; } debug(`FaceDetector: found ${filtered.length} face(s) (from ${faces.length} raw detections)`); return filtered; } catch (e) { debug(`FaceDetector: failed — ${e.message || e}`); return null; } } // --------------------------------------------------------------------------- // Tier 2: Skin-color heuristic (YCbCr on downscaled canvas) // --------------------------------------------------------------------------- /** * Estimate face position by detecting clusters of skin-tone pixels. * * Down-scales the image to a small canvas, converts each pixel to YCbCr, * classifies skin pixels using established chrominance thresholds, then * finds the grid cell with the highest density and expands to adjacent * cells above a threshold to produce a bounding rectangle. * * @param {HTMLImageElement} img * @returns {Array<{x:number,y:number,width:number,height:number}>|null} */ function detectFacesSkinHeuristic(img) { const srcW = img.naturalWidth; const srcH = img.naturalHeight; const cfg = FACE_DETECT_CONFIG; // Down-scale to a small sample canvas to keep cost under ~5ms const scale = Math.min(cfg.SKIN_SAMPLE_SIZE / srcW, cfg.SKIN_SAMPLE_SIZE / srcH); const cw = Math.max(1, Math.round(srcW * scale)); const ch = Math.max(1, Math.round(srcH * scale)); const canvas = document.createElement('canvas'); canvas.width = cw; canvas.height = ch; const ctx = canvas.getContext('2d'); if (!ctx) return null; ctx.drawImage(img, 0, 0, cw, ch); let imageData; try { imageData = ctx.getImageData(0, 0, cw, ch); } catch (_) { debug('Skin heuristic: canvas tainted, cannot read pixels'); return null; } const { data } = imageData; const gridCols = cfg.SKIN_GRID_COLS; const gridRows = cfg.SKIN_GRID_ROWS; const grid = Array.from({ length: gridRows }, () => new Array(gridCols).fill(0)); const cellW = cw / gridCols; const cellH = ch / gridRows; let totalSkin = 0; // Classify each pixel using YCbCr chrominance thresholds for (let i = 0; i < data.length; i += 4) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; // RGB → YCbCr (ITU-R BT.601 simplified) const cb = 128 - 0.169 * r - 0.331 * g + 0.500 * b; const cr = 128 + 0.500 * r - 0.419 * g - 0.081 * b; if (cb >= cfg.SKIN_CB_MIN && cb <= cfg.SKIN_CB_MAX && cr >= cfg.SKIN_CR_MIN && cr <= cfg.SKIN_CR_MAX) { const px = (i / 4) % cw; const py = Math.floor((i / 4) / cw); const col = Math.min(Math.floor(px / cellW), gridCols - 1); const row = Math.min(Math.floor(py / cellH), gridRows - 1); grid[row][col]++; totalSkin++; } } // Not enough skin pixels to be meaningful if (totalSkin < cfg.SKIN_MIN_PIXELS) { debug(`Skin heuristic: only ${totalSkin} skin pixels (need ${cfg.SKIN_MIN_PIXELS})`); return null; } // Find the densest cell let maxDensity = 0; let bestRow = 0; let bestCol = 0; for (let r = 0; r < gridRows; r++) { for (let c = 0; c < gridCols; c++) { if (grid[r][c] > maxDensity) { maxDensity = grid[r][c]; bestRow = r; bestCol = c; } } } // Expand to adjacent cells with ≥50% of peak density const threshold = maxDensity * 0.5; let minCol = bestCol; let maxCol = bestCol; let minRow = bestRow; let maxRow = bestRow; for (let r = 0; r < gridRows; r++) { for (let c = 0; c < gridCols; c++) { if (grid[r][c] >= threshold) { if (c < minCol) minCol = c; if (c > maxCol) maxCol = c; if (r < minRow) minRow = r; if (r > maxRow) maxRow = r; } } } // Map bounding rectangle back to source coordinates const faceX = minCol * (srcW / gridCols); const faceY = minRow * (srcH / gridRows); const faceW = (maxCol - minCol + 1) * (srcW / gridCols); const faceH = (maxRow - minRow + 1) * (srcH / gridRows); debug(`Skin heuristic: cluster at (${faceX.toFixed(0)}, ${faceY.toFixed(0)}), ${faceW.toFixed(0)}×${faceH.toFixed(0)}, ${totalSkin} skin pixels`); return [{ x: faceX, y: faceY, width: faceW, height: faceH }]; } // --------------------------------------------------------------------------- // Crop geometry // --------------------------------------------------------------------------- /** * Compute the source crop rectangle that places the attention point at * the given vertical bias from the top of the output, while clamping to * image bounds so the crop window never extends outside the source. * * @param {number} srcW - Source image width * @param {number} srcH - Source image height * @param {number} attentionX - Horizontal attention point (px) * @param {number} attentionY - Vertical attention point (px) * @param {number} bias - Where the attention point should land vertically * in the output: 0 = top edge, 0.5 = center, 1 = bottom edge. * @returns {{ sx: number, sy: number, sw: number, sh: number }} */ function computeCropRect(srcW, srcH, attentionX, attentionY, bias) { const targetW = IMAGE_POOL_CONFIG.OUTPUT_WIDTH; const targetH = IMAGE_POOL_CONFIG.OUTPUT_HEIGHT; const targetRatio = targetW / targetH; const srcRatio = srcW / srcH; let sx, sy, sw, sh; if (Math.abs(srcRatio - targetRatio) < 0.01) { // Already the correct ratio — no crop needed sx = 0; sy = 0; sw = srcW; sh = srcH; } else if (srcRatio > targetRatio) { // Too wide — crop sides, center horizontally around the attention point sh = srcH; sw = sh * targetRatio; sx = attentionX - sw / 2; sy = 0; } else { // Too tall — crop top/bottom, position attention point at bias from top sw = srcW; sh = sw / targetRatio; sx = 0; sy = attentionY - sh * bias; } // Clamp to image bounds sx = Math.max(0, Math.min(sx, srcW - sw)); sy = Math.max(0, Math.min(sy, srcH - sh)); return { sx, sy, sw, sh }; } /** * Render the crop rectangle to a canvas and export as JPEG. * * @param {HTMLImageElement} img * @param {{ sx: number, sy: number, sw: number, sh: number }} cropRect * @returns {{ dataUrl: string, width: number, height: number }} */ function renderCrop(img, cropRect) { const { sx, sy, sw, sh } = cropRect; const targetW = IMAGE_POOL_CONFIG.OUTPUT_WIDTH; const targetH = IMAGE_POOL_CONFIG.OUTPUT_HEIGHT; const canvas = document.createElement('canvas'); canvas.width = targetW; canvas.height = targetH; const ctx = canvas.getContext('2d'); ctx.drawImage(img, sx, sy, sw, sh, 0, 0, targetW, targetH); const dataUrl = canvas.toDataURL('image/jpeg', IMAGE_POOL_CONFIG.JPEG_QUALITY); return { dataUrl, width: targetW, height: targetH }; } /** * @file Image pool — persistent, uploadable, deduplicated face-image pool. * * Storage is provided by utils/storage-adapter.js (GM → localStorage → memory). * * Upload pipeline: * File → FileReader → validate MIME / size → decode * → smart-crop to 400×300 (face-biased) → JPEG compress * → dHash perceptual fingerprint → dedup check → store * * Each image stores TWO keys: * bfw_img_{id} — cropped 400×300 JPEG (what the system uses) * bfw_img_orig_{id} — original un-cropped data URI (for manual crop editing) * * Pick pipeline: * pickImage() → load metadata → pool empty? → fallback canvas face * → random index → load & validate stored image * → load fails? → evict entry, retry (up to 3×) * → apply random mutations (brightness/contrast/saturation/hue * flip/rotate/scale-jitter + JPEG quality jitter) * → return mutated image (bytes differ every call) */ // --------------------------------------------------------------------------- // Image validation & processing // --------------------------------------------------------------------------- /** * Validate that a string looks like a base64 data URI we can use. * @param {string} str * @returns {boolean} */ function isValidDataURI(str) { return typeof str === 'string' && str.startsWith('data:image/') && str.length > 50 && str.length < IMAGE_POOL_CONFIG.MAX_FILE_SIZE * 1.5; // base64 ~33% larger } /** * Fisher-Yates shuffle (in-place). * @template T * @param {T[]} arr * @returns {T[]} the same array */ function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } /** * Compress an image data URI to a smaller size suitable for long-term storage. * Resizes to max 1200px on the longest side, exports as JPEG at quality 0.65. * A 12MP phone photo (~5 MB base64) compresses to ~80–150 KB. * * @param {string} dataUrl - source data URI * @returns {Promise} compressed JPEG data URI */ function compressOriginal(dataUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.onerror = () => reject(new Error('Failed to decode for compression')); img.onload = () => { try { const maxDim = IMAGE_POOL_CONFIG.ORIG_MAX_DIMENSION; let w = img.naturalWidth; let h = img.naturalHeight; if (w <= maxDim && h <= maxDim && dataUrl.length < 200 * 1024) { // Already small enough — return as-is resolve(dataUrl); return; } const scale = Math.min(maxDim / w, maxDim / h); w = Math.round(w * scale); h = Math.round(h * scale); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); resolve(canvas.toDataURL('image/jpeg', IMAGE_POOL_CONFIG.ORIG_JPEG_QUALITY)); } catch (e) { reject(e); } }; img.src = dataUrl; }); } // --------------------------------------------------------------------------- // Mutation engine — applied at pick-time to make every output byte-unique // --------------------------------------------------------------------------- /** Cached offscreen canvas for mutateImage (400×300, constant size). * Reusing a single canvas avoids repeated createElement + GC pressure on the * hot path — pickImage() is called every time the camera interceptor * activates (face verification modal opens). */ let _mutationCanvas = null; /** @returns {{ canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D }} */ function getMutationCanvas() { if (!_mutationCanvas) { _mutationCanvas = document.createElement('canvas'); _mutationCanvas.width = IMAGE_POOL_CONFIG.OUTPUT_WIDTH; _mutationCanvas.height = IMAGE_POOL_CONFIG.OUTPUT_HEIGHT; } const ctx = _mutationCanvas.getContext('2d'); return { canvas: _mutationCanvas, ctx }; } /** * Apply a random set of low-level mutations to a 400×300 image. * Each mutation has an independent activation chance → on average 2–4 fire. * * Transforms are composed into a SINGLE draw call (canvas filter + transform) * to avoid quality degradation from multi-pass rendering. * * Mutation catalogue (applied in composited order): * brightness : ×0.85–1.15 * contrast : ×0.88–1.12 * saturation : ×0.85–1.15 * hue : ±4° * flip : horizontal mirror (50% dice) * rotate : ±2.5° (background filled black, draw 8% oversize) * scale-jitter: ×1.00–1.06 per axis (independent, floor=1.0 prevents black borders) * JPEG quality: 0.72–0.85 random * * Reuses a single offscreen canvas for all mutation calls to reduce GC pressure. * * @param {string} sourceDataUrl - the stored clean image * @returns {Promise} mutated JPEG data URI */ function mutateImage(sourceDataUrl) { return new Promise((resolve, reject) => { const img = new Image(); img.onerror = () => reject(new Error('Mutation: failed to load source image')); img.onload = () => { try { const cfg = IMAGE_POOL_CONFIG; const targetW = cfg.OUTPUT_WIDTH; const targetH = cfg.OUTPUT_HEIGHT; const { canvas, ctx } = getMutationCanvas(); // Background fill (handles rotation corners / scale shrink) ctx.fillStyle = '#000'; ctx.fillRect(0, 0, targetW, targetH); // ---- Gather mutations ---- /** @type {string[]} */ const active = []; if (Math.random() < cfg.MUTATION_CHANCE_BRIGHTNESS) active.push('brightness'); if (Math.random() < cfg.MUTATION_CHANCE_CONTRAST) active.push('contrast'); if (Math.random() < cfg.MUTATION_CHANCE_SATURATION) active.push('saturation'); if (Math.random() < cfg.MUTATION_CHANCE_HUE) active.push('hue'); // Shuffle so the same string doesn't repeat often shuffle(active); // ---- Build CSS filter ---- /** @type {string[]} */ const filters = []; for (const m of active) { switch (m) { case 'brightness': { const v = lerp(cfg.MUTATION_BRIGHTNESS_RANGE, Math.random()).toFixed(2); filters.push(`brightness(${v})`); break; } case 'contrast': { const v = lerp(cfg.MUTATION_CONTRAST_RANGE, Math.random()).toFixed(2); filters.push(`contrast(${v})`); break; } case 'saturation': { const v = lerp(cfg.MUTATION_SATURATION_RANGE, Math.random()).toFixed(2); filters.push(`saturate(${v})`); break; } case 'hue': { const v = lerp(cfg.MUTATION_HUE_RANGE, Math.random()).toFixed(1); filters.push(`hue-rotate(${v}deg)`); break; } } } if (filters.length > 0) { ctx.filter = filters.join(' '); } // ---- Build transform ---- ctx.save(); ctx.translate(targetW / 2, targetH / 2); const flip = Math.random() < cfg.MUTATION_CHANCE_FLIP ? -1 : 1; const jitter = Math.random() < cfg.MUTATION_CHANCE_SCALE_JITTER; const sx = flip * (jitter ? lerp(cfg.MUTATION_SCALE_RANGE, Math.random()) : 1); const sy = jitter ? lerp(cfg.MUTATION_SCALE_RANGE, Math.random()) : 1; const doRotate = Math.random() < cfg.MUTATION_CHANCE_ROTATE; const angle = doRotate ? lerp(cfg.MUTATION_ROTATE_RANGE, Math.random()) * Math.PI / 180 : 0; ctx.transform(sx, 0, 0, sy, 0, 0); if (angle) ctx.rotate(angle); // Draw slightly larger when rotating so corners stay filled const margin = doRotate ? 1.08 : 1.0; ctx.drawImage( img, -targetW * margin / 2, -targetH * margin / 2, targetW * margin, targetH * margin, ); ctx.restore(); ctx.filter = 'none'; // ---- Export with quality jitter ---- const q = lerp(cfg.MUTATION_QUALITY_RANGE, Math.random()).toFixed(3); const dataUrl = canvas.toDataURL('image/jpeg', Number(q)); resolve(dataUrl); } catch (e) { reject(e); } }; img.src = sourceDataUrl; }); } /** * Linear interpolation in [range[0], range[1]]. * @param {[number,number]} range * @param {number} t 0–1 * @returns {number} */ function lerp(range, t) { return range[0] + (range[1] - range[0]) * t; } /** Cached 9×8 offscreen canvas for dHash computation. Reusing a single * canvas avoids repeated createElement + GC pressure during batch uploads * where computeDHash() is called for every file. */ let _dHashCanvas = null; /** * Compute a 64-bit dHash (difference hash) from an Image element. * Returns a BigInt (0n – 0xFFFFFFFFFFFFFFFFn) or null on failure. * * Algorithm: shrink to 9×8, compare each pixel's luminance with its * right neighbour → 64 bits. * * @param {HTMLImageElement} img * @returns {bigint|null} */ function computeDHash(img) { try { if (!_dHashCanvas) { _dHashCanvas = document.createElement('canvas'); _dHashCanvas.width = 9; _dHashCanvas.height = 8; } const ctx = _dHashCanvas.getContext('2d'); if (!ctx) return null; ctx.drawImage(img, 0, 0, 9, 8); const { data } = ctx.getImageData(0, 0, 9, 8); let hash = 0n; for (let y = 0; y < 8; y++) { for (let x = 0; x < 8; x++) { const idx = (y * 9 + x) * 4; // Perceived luminance const lumA = data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114; const lumB = data[(y * 9 + x + 1) * 4] * 0.299 + data[(y * 9 + x + 1) * 4 + 1] * 0.587 + data[(y * 9 + x + 1) * 4 + 2] * 0.114; hash = (hash << 1n) | (lumA > lumB ? 1n : 0n); } } return hash; } catch (e) { debug('dHash computation failed:', e); return null; } } /** * Hamming distance between two BigInts (popcount of XOR). * @param {bigint} a * @param {bigint} b * @returns {number} */ function hammingDistance(a, b) { let xor = a ^ b; let dist = 0; while (xor) { dist++; xor &= xor - 1n; } return dist; } /** * Process a File object into a pool-ready entry. * Returns the entry or null if the file is invalid / unreadable. * * @param {File} file * @param {bigint[]} existingHashes - hashes already in the pool * @returns {Promise} */ function processUploadedFile(file, existingHashes) { return new Promise((resolve) => { // 1. Size guard if (file.size > IMAGE_POOL_CONFIG.MAX_FILE_SIZE) { warn(`File "${file.name}" exceeds max size (${(file.size / 1e6).toFixed(1)}MB > ${IMAGE_POOL_CONFIG.MAX_FILE_SIZE / 1e6}MB), skipping`); return resolve(null); } // 2. MIME guard if (!IMAGE_POOL_CONFIG.ACCEPTED_TYPES.includes(file.type) && file.type !== '') { // Allow empty MIME (some systems don't set it) but reject known non-images if (file.type.startsWith('video/') || file.type.startsWith('audio/') || file.type.startsWith('application/') || file.type.startsWith('text/')) { warn(`File "${file.name}" is not an image (${file.type}), skipping`); return resolve(null); } } // 3. Read as data URL const reader = new FileReader(); reader.onerror = () => { warn(`Failed to read file "${file.name}"`); resolve(null); }; reader.onload = () => { const dataUrl = /** @type {string} */ (reader.result); // 4. Decode via Image to validate const img = new Image(); img.onerror = () => { warn(`File "${file.name}" could not be decoded as an image, skipping`); resolve(null); }; img.onload = async () => { // 5. Smart-crop to standard size (async: may use FaceDetector) const { dataUrl: cropped, width, height, cropRect } = await smartCropToStandard(img); // 6. Compress original for quota-friendly storage let origDataUrl; try { origDataUrl = await compressOriginal(dataUrl); } catch (e) { warn(`Failed to compress original for "${file.name}":`, e); origDataUrl = null; } // 7. Perceptual hash for dedup const hash = computeDHash(img); if (hash !== null) { const threshold = IMAGE_POOL_CONFIG.DEDUP_HAMMING_THRESHOLD; const duplicate = existingHashes.some((h) => hammingDistance(h, hash) <= threshold); if (duplicate) { debug(`File "${file.name}" is a perceptual duplicate, skipping`); return resolve(null); } } debug(`Processed "${file.name}": ${file.size} → ${cropped.length} bytes, ${width}×${height}`); resolve({ name: file.name, dataUrl: cropped, origDataUrl: origDataUrl, origWidth: img.naturalWidth, origHeight: img.naturalHeight, hash: hash ? hash.toString(16) : null, size: cropped.length, width, height, cropParams: origDataUrl ? cropRect : null, }); }; img.src = dataUrl; }; reader.readAsDataURL(file); }); } // --------------------------------------------------------------------------- // Pool state & metadata // --------------------------------------------------------------------------- /** * @typedef {Object} PoolEntry * @property {number} id - stable index * @property {string} name - original filename * @property {string} hash - hex dHash string (or null) * @property {number} size - data URI length in bytes (cropped image) * @property {number} width - pixels (cropped, always 400) * @property {number} height - pixels (cropped, always 300) * @property {number} addedAt - epoch ms * @property {{sx:number,sy:number,sw:number,sh:number}|null} cropParams - source crop rectangle, null if unavailable * @property {number} origWidth - original (un-cropped) image width in px (after compression) * @property {number} origHeight - original (un-cropped) image height in px (after compression) */ /** @typedef {'high'|'neutral'|'low'} QualityTier */ /** * @typedef {Object} ImageStats * @property {number} totalUses - number of times this image was picked * @property {number} successes - number of times it led to a passed verification * @property {number} failures - number of times it led to a failed verification * @property {number} lastUsedAt - epoch ms of last pick * @property {'success'|'fail'|null} lastResult - outcome of last verification attempt */ /** * @typedef {Object} PoolMeta * @property {number} version * @property {number} nextId * @property {PoolEntry[]} entries */ /** In-memory metadata cache */ let _meta = { version: 1, nextId: 0, entries: [] }; /** In-memory stats cache — { [id: number]: ImageStats } */ let _stats = {}; /** ID of the most recently picked image (set by pickImage, read by recordLastPickResult). */ let _lastPickedId = null; /** Whether init() has completed */ let _ready = false; function imgKey(id) { return IMAGE_POOL_CONFIG.STORAGE_KEY_PREFIX + id; } function imgOrigKey(id) { return IMAGE_POOL_CONFIG.STORAGE_KEY_PREFIX + 'orig_' + id; } /** * Load metadata from storage. If metadata is missing but image keys exist, * rebuild metadata from surviving keys (self-healing). */ async function loadMeta() { const adapter = getStorageAdapter(); const raw = await adapter.get(IMAGE_POOL_CONFIG.META_KEY); if (raw) { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed.version === 'number' && typeof parsed.nextId === 'number' && Array.isArray(parsed.entries)) { _meta = parsed; debug(`Image pool: loaded ${_meta.entries.length} entries (nextId=${_meta.nextId})`); return; } } catch (e) { warn('Image pool: metadata corrupted, attempting rebuild'); } } // Self-heal: scan for orphaned image keys const allKeys = await adapter.keys(); const prefix = IMAGE_POOL_CONFIG.STORAGE_KEY_PREFIX; const orphanIds = allKeys .filter((k) => k.startsWith(prefix) && k !== IMAGE_POOL_CONFIG.META_KEY && !k.includes('orig_')) .map((k) => { const idStr = k.slice(prefix.length); const id = parseInt(idStr, 10); return Number.isFinite(id) ? id : -1; }) .filter((id) => id >= 0) .sort((a, b) => a - b); if (orphanIds.length > 0) { info(`Image pool: found ${orphanIds.length} orphaned images, rebuilding metadata`); const entries = []; for (const id of orphanIds) { const data = await adapter.get(imgKey(id)); if (data && isValidDataURI(data)) { entries.push({ id, name: `recovered_${id}`, hash: null, size: data.length, width: 0, height: 0, addedAt: Date.now(), cropParams: null, origWidth: 0, origHeight: 0, }); } else { // Dead key — clean up await adapter.remove(imgKey(id)); } } _meta = { version: 1, nextId: orphanIds.length > 0 ? Math.max(...orphanIds) + 1 : 0, entries, }; await persistMeta(); } else { _meta = { version: 1, nextId: 0, entries: [] }; } } /** * Write metadata to storage (best-effort). */ async function persistMeta() { try { await getStorageAdapter().set(IMAGE_POOL_CONFIG.META_KEY, JSON.stringify(_meta)); } catch (e) { warn('Image pool: failed to persist metadata:', e); } } /** * Remove an image key from storage (best-effort). */ async function removeImageData(id) { try { await getStorageAdapter().remove(imgKey(id)); } catch (e) { warn(`Image pool: failed to remove image ${id}:`, e); } } // --------------------------------------------------------------------------- // Stats tracking — per-image quality scoring // --------------------------------------------------------------------------- /** * Create a fresh (zeroed) stats object for a new image. * @returns {ImageStats} */ function createDefaultStats() { return { totalUses: 0, successes: 0, failures: 0, lastUsedAt: 0, lastResult: null }; } /** * Load per-image stats from storage. Self-heals: removes entries for * images that no longer exist in metadata. */ async function loadStats() { const adapter = getStorageAdapter(); const raw = await adapter.get(IMAGE_POOL_CONFIG.STATS_KEY); if (raw) { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { // Keep only stats for images that still exist const validIds = new Set(_meta.entries.map((e) => e.id)); const cleaned = {}; let removed = 0; for (const [idStr, s] of Object.entries(parsed)) { const id = Number(idStr); if (validIds.has(id) && s && typeof s.totalUses === 'number') { cleaned[id] = s; } else { removed++; } } _stats = cleaned; if (removed > 0) { debug(`Image pool stats: removed ${removed} orphaned entries`); await persistStats(); } else { debug(`Image pool stats: loaded ${Object.keys(_stats).length} entries`); } return; } } catch (e) { warn('Image pool stats: corrupted, resetting'); } } _stats = {}; } /** * Write stats to storage (best-effort). */ async function persistStats() { try { await getStorageAdapter().set(IMAGE_POOL_CONFIG.STATS_KEY, JSON.stringify(_stats)); } catch (e) { warn('Image pool: failed to persist stats:', e); } } /** * Get or create stats for an image ID. * @param {number} id * @returns {ImageStats} */ function getOrCreateStats(id) { if (!_stats[id]) { _stats[id] = createDefaultStats(); } return _stats[id]; } /** * Determine the quality tier for given stats. * @param {ImageStats} stats * @returns {QualityTier} */ function getQualityTier(stats) { const cfg = IMAGE_POOL_CONFIG.QUALITY_SCORING; if (!stats || stats.totalUses < cfg.MIN_USES_FOR_ASSESSMENT) return 'neutral'; const failRate = stats.failures / stats.totalUses; if (stats.failures >= cfg.LOW_QUALITY_FAILURE_THRESHOLD && failRate >= cfg.LOW_QUALITY_FAIL_RATE) { return 'low'; } if (stats.totalUses >= cfg.HIGH_QUALITY_MIN_USES) { const successRate = stats.successes / stats.totalUses; if (successRate >= cfg.HIGH_QUALITY_SUCCESS_RATE) return 'high'; } return 'neutral'; } /** * Compute selection weights for all pool entries based on their quality tier. * Returns an array aligned with `_meta.entries`. Every weight is strictly > 0. * @returns {number[]} */ function computeWeights() { const cfg = IMAGE_POOL_CONFIG.QUALITY_SCORING; return _meta.entries.map((entry) => { const stats = _stats[entry.id] || createDefaultStats(); const tier = getQualityTier(stats); switch (tier) { case 'high': return cfg.HIGH_QUALITY_WEIGHT; case 'low': return cfg.LOW_QUALITY_WEIGHT; default: return cfg.NEUTRAL_WEIGHT; } }); } /** * Weighted random index into `_meta.entries`. * Falls back to uniform random if weights sum to zero (should not happen). * @param {number[]} weights * @returns {number} index into _meta.entries */ function weightedRandomIndex(weights) { const total = weights.reduce((a, b) => a + b, 0); if (total <= 0) return Math.floor(Math.random() * _meta.entries.length); let target = Math.random() * total; for (let i = 0; i < weights.length; i++) { target -= weights[i]; if (target <= 0) return i; } return weights.length - 1; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Initialize the image pool. Must be called once before any other operations. * Idempotent — subsequent calls are no-ops. * * @returns {Promise} */ async function initPool() { if (_ready) return; await loadMeta(); await loadStats(); _ready = true; info(`Image pool ready: ${_meta.entries.length}/${IMAGE_POOL_CONFIG.MAX_IMAGES} images`); } /** * How many images are currently in the pool? * @returns {number} */ function poolSize() { return _meta.entries.length; } /** * Maximum capacity. * @returns {number} */ function poolCapacity() { return IMAGE_POOL_CONFIG.MAX_IMAGES; } /** * Return a shallow copy of all pool entries (for UI rendering). * @returns {PoolEntry[]} */ function listEntries() { return _meta.entries.slice(); } /** * Pick a random image from the pool using weighted random selection. * High-quality images are boosted; low-quality images are down-weighted * but never fully excluded. Falls back to uniform random if the pool * has no stats data yet. * * On every call the chosen image is run through mutateImage() so that * no two requests ever return the same bytes — brightness, contrast, * saturation, hue, flip, rotation, scale, and JPEG quality are all * randomized per invocation. * * Also records usage stats: increments totalUses and sets lastUsedAt * so that the quality scoring system can track per-image performance. * * @returns {Promise} base64 JPEG data URI */ async function pickImage() { if (_meta.entries.length === 0) { const err = new Error('Image pool is empty — cannot pick an image for replacement'); err.code = 'POOL_EMPTY'; throw err; } const maxRetries = 3; const tried = new Set(); // Compute weights once — they don't change during retries const useWeighted = getSetting('dynamicWeight', true); const weights = useWeighted ? computeWeights() : null; for (let attempt = 0; attempt < maxRetries && tried.size < _meta.entries.length; attempt++) { // Weighted or uniform random selection let idx; do { idx = useWeighted ? weightedRandomIndex(weights) : Math.floor(Math.random() * _meta.entries.length); } while (tried.has(idx)); tried.add(idx); const entry = _meta.entries[idx]; const adapter = getStorageAdapter(); const raw = await adapter.get(imgKey(entry.id)); if (raw && isValidDataURI(raw)) { // Record usage stats _lastPickedId = entry.id; const stats = getOrCreateStats(entry.id); info(`Picked image #${entry.id} "${entry.name}" (tier=${getQualityTier(stats)}, prevUses=${stats.totalUses})`); stats.totalUses++; stats.lastUsedAt = Date.now(); persistStats(); // fire-and-forget // Apply random mutations if enabled { try { const mutated = await mutateImage(raw); return mutated; } catch (e) { // Mutation failed — return the clean copy as fallback warn(`Mutation failed for "${entry.name}", returning clean copy:`, e); return raw; } } return raw; } // Stale/dead entry — evict warn(`Image ${entry.id} ("${entry.name}") data missing or invalid, evicting`); await removeImageData(entry.id); _meta.entries.splice(idx, 1); // Also remove stale stats delete _stats[entry.id]; await persistMeta(); await persistStats(); } // All retries exhausted — every entry failed to load or validate warn(`All ${_meta.entries.length} pool images failed to load from storage — cannot pick an image`); const err = new Error('All pool images failed to load — storage may be corrupted or inaccessible'); err.code = 'POOL_EMPTY'; throw err; } /** * Add images from File objects to the pool. * * @param {File[]} files * @returns {Promise<{ added: PoolEntry[], skipped: number, duplicates: number }>} */ async function addImages(files) { if (!_ready) await initPool(); const existingHashes = _meta.entries .map((e) => { if (e.hash) { try { return BigInt('0x' + e.hash); } catch (_) { return null; } } return null; }) .filter(Boolean); const results = { added: [], skipped: 0, duplicates: 0 }; for (const file of files) { // Capacity check if (_meta.entries.length + results.added.length >= IMAGE_POOL_CONFIG.MAX_IMAGES) { warn(`Image pool full (${IMAGE_POOL_CONFIG.MAX_IMAGES}), skipping "${file.name}"`); results.skipped++; continue; } const processed = await processUploadedFile(file, [...existingHashes, ...results.added.map((e) => { if (e.hash) { try { return BigInt('0x' + e.hash); } catch (_) { return 0n; } } return 0n; }).filter((h) => h !== 0n)]); if (!processed) { results.skipped++; continue; } const id = _meta.nextId++; const entry = { id, name: processed.name, hash: processed.hash, size: processed.size, width: processed.width, height: processed.height, addedAt: Date.now(), cropParams: processed.cropParams || null, origWidth: processed.origWidth || 0, origHeight: processed.origHeight || 0, }; // Persist cropped image (must succeed); original is best-effort try { await getStorageAdapter().set(imgKey(id), processed.dataUrl); } catch (e) { warn(`Failed to store cropped image "${file.name}":`, e); results.skipped++; continue; } // Store original — non-blocking: if it fails we keep the cropped image // but disable crop editing (cropParams stays null) if (processed.origDataUrl) { try { await getStorageAdapter().set(imgOrigKey(id), processed.origDataUrl); } catch (e) { warn(`Failed to store original for "${file.name}" (quota), crop editing disabled`); entry.cropParams = null; } } else { entry.cropParams = null; } _meta.entries.push(entry); results.added.push(entry); info(`Image "${file.name}" added to pool (id=${id}, ${processed.width}×${processed.height})`); } if (results.added.length > 0 || results.skipped > 0) { await persistMeta(); } return results; } /** * Add a single image from a data URI (canvas capture, screenshot, etc.). * Skips the File/FileReader path — goes directly to decode → compress → * dHash dedup → storage. Designed for programmatic frame capture from * <video> elements. * * @param {string} dataUrl - A base64 data URI (data:image/…) * @param {string} name - Human-readable label (e.g. "captured_1712345678901") * @returns {Promise} The added entry, or null if rejected */ async function addImageFromDataURI(dataUrl, name) { if (!_ready) await initPool(); // 1. Quick sanity — must look like a data URI if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) { warn('addImageFromDataURI: not a valid image data URI'); return null; } if (dataUrl.length > IMAGE_POOL_CONFIG.MAX_FILE_SIZE * 2) { warn('addImageFromDataURI: data URI too large before compression, rejecting'); return null; } // 2. Capacity check if (_meta.entries.length >= IMAGE_POOL_CONFIG.MAX_IMAGES) { warn('Image pool full, rejecting captured frame'); return null; } // 3. Build existing hashes for dedup const existingHashes = _meta.entries .map((e) => { if (e.hash) { try { return BigInt('0x' + e.hash); } catch (_) { return null; } } return null; }) .filter(Boolean); // 4. Decode, compress, dHash return new Promise((resolve) => { const img = new Image(); img.onerror = () => { warn('addImageFromDataURI: failed to decode image'); resolve(null); }; img.onload = async () => { // Compress via smart-crop to standard dimensions (async: may use FaceDetector) const { dataUrl: compressed, width, height, cropRect } = await smartCropToStandard(img); // Compress original for quota-friendly storage let origDataUrl; try { origDataUrl = await compressOriginal(dataUrl); } catch (e) { warn(`Failed to compress original for "${name}":`, e); origDataUrl = null; } // Perceptual dedup const hash = computeDHash(img); if (hash !== null) { const threshold = IMAGE_POOL_CONFIG.DEDUP_HAMMING_THRESHOLD; const duplicate = existingHashes.some((h) => hammingDistance(h, hash) <= threshold); if (duplicate) { debug(`Captured frame "${name}" is a perceptual duplicate, skipping`); return resolve(null); } } // 5. Allocate id & persist const id = _meta.nextId++; const entry = { id, name, hash: hash ? hash.toString(16) : null, size: compressed.length, width, height, addedAt: Date.now(), cropParams: (cropRect && origDataUrl) ? cropRect : null, origWidth: img.naturalWidth, origHeight: img.naturalHeight, }; // Persist cropped image (must succeed) try { await getStorageAdapter().set(imgKey(id), compressed); } catch (e) { warn(`Failed to store captured image "${name}":`, e); return resolve(null); } // Store original — non-blocking: if it fails we keep the cropped image if (origDataUrl) { try { await getStorageAdapter().set(imgOrigKey(id), origDataUrl); } catch (e) { warn(`Failed to store original for "${name}" (quota), crop editing disabled`); entry.cropParams = null; } } _meta.entries.push(entry); await persistMeta(); info(`Captured image "${name}" added to pool (id=${id}, ${width}×${height})`); resolve(entry); }; img.src = dataUrl; }); } /** * Remove a single image by its stable id. * @param {number} id * @returns {Promise} true if removed */ async function removeImage(id) { const idx = _meta.entries.findIndex((e) => e.id === id); if (idx === -1) return false; const entry = _meta.entries[idx]; await removeImageData(entry.id); try { await getStorageAdapter().remove(imgOrigKey(entry.id)); } catch (_) { /* ignore */ } _meta.entries.splice(idx, 1); // Clean up stats for the removed image delete _stats[entry.id]; await persistMeta(); await persistStats(); info(`Removed image "${entry.name}" (id=${id})`); return true; } /** * Remove all images from the pool. * @returns {Promise} */ async function clearPool() { const adapter = getStorageAdapter(); for (const entry of _meta.entries) { await adapter.remove(imgKey(entry.id)); try { await adapter.remove(imgOrigKey(entry.id)); } catch (_) { /* ignore */ } } _meta.entries = []; _meta.nextId = 0; _stats = {}; _lastPickedId = null; await persistMeta(); await persistStats(); info('Image pool cleared'); } /** * Return the data URI for a specific entry (for thumbnail display). * @param {number} id * @returns {Promise} */ async function getImageData(id) { const raw = await getStorageAdapter().get(imgKey(id)); return raw && isValidDataURI(raw) ? raw : null; } /** * Return the ORIGINAL (un-cropped) data URI for a specific entry. * @param {number} id * @returns {Promise} */ async function getOriginalImageData(id) { const raw = await getStorageAdapter().get(imgOrigKey(id)); return raw && typeof raw === 'string' && raw.startsWith('data:image/') ? raw : null; } /** * Update the crop rectangle for an existing image, re-render the cropped * output, and persist everything back to storage. * * @param {number} id * @param {{sx:number, sy:number, sw:number, sh:number}} cropParams — new crop rect in source-pixel coordinates * @returns {Promise} true if successful */ async function updateCrop(id, cropParams) { const idx = _meta.entries.findIndex((e) => e.id === id); if (idx === -1) { warn(`updateCrop: image ${id} not found`); return false; } const origDataUrl = await getOriginalImageData(id); if (!origDataUrl) { warn(`updateCrop: original image data not found for ${id}`); return false; } return new Promise((resolve) => { const img = new Image(); img.onerror = () => { warn(`updateCrop: failed to decode original image for ${id}`); resolve(false); }; img.onload = async () => { try { const { dataUrl, width, height } = renderCrop(img, cropParams); // Persist new cropped image await getStorageAdapter().set(imgKey(id), dataUrl); // Update metadata — sync origWidth/origHeight to the loaded image // dimensions so that on re-open the coordinate system matches. _meta.entries[idx].cropParams = { ...cropParams }; _meta.entries[idx].size = dataUrl.length; _meta.entries[idx].width = width; _meta.entries[idx].height = height; _meta.entries[idx].origWidth = img.naturalWidth; _meta.entries[idx].origHeight = img.naturalHeight; await persistMeta(); debug(`Crop updated for image ${id}: source (${cropParams.sx},${cropParams.sy}) ${cropParams.sw}×${cropParams.sh}`); resolve(true); } catch (e) { warn(`updateCrop: render failed for ${id}:`, e); resolve(false); } }; img.src = origDataUrl; }); } // --------------------------------------------------------------------------- // Stats public API — per-image quality tracking // --------------------------------------------------------------------------- /** * Record a verification result for a specific image. * Updates successes/failures/lastResult and persists stats. * * @param {number} id - Image ID * @param {boolean} success - Whether verification succeeded */ function recordImageResult(id, success) { const stats = getOrCreateStats(id); if (success) { stats.successes++; stats.lastResult = 'success'; } else { stats.failures++; stats.lastResult = 'fail'; } // If lastUsedAt is 0 (never picked directly but result recorded), set now if (!stats.lastUsedAt) stats.lastUsedAt = Date.now(); persistStats(); // fire-and-forget const tier = getQualityTier(stats); info(`Image ${id} verification: ${success ? '✓ SUCCESS' : '✗ FAIL'} (totalUses=${stats.totalUses}, success=${stats.successes}/${stats.totalUses}, tier=${tier})`); } /** * Record a verification result for the most recently picked image. * Convenience wrapper — the processor doesn't know the exact image ID; * it just knows the current verification attempt succeeded or failed. * * @param {boolean} success */ function recordLastPickResult(success) { if (_lastPickedId == null) { info('recordLastPickResult: no image was picked yet — cannot record result'); return; } recordImageResult(_lastPickedId, success); } /** * Get the stats for a specific image. * @param {number} id * @returns {ImageStats|null} */ function getImageStats(id) { return _stats[id] || null; } /** * Get the quality tier for a specific image. * @param {number} id * @returns {QualityTier} */ function getImageQualityTier(id) { const stats = _stats[id] || createDefaultStats(); return getQualityTier(stats); } /** * @file CSS styles for the edge-drawer panel UI * All styles are injected as a