scripts/engine.jsjavascript
/** @module engine */
import { state } from './state.js';
import { els, clearOverlay } from './dom.js';
import { avgPoint, drawClosedPath, drawOpenPath, roundRect } from './utils.js';
import { resizeCanvas } from './camera.js';
import { persistDb, renderDbStats } from './db.js';
import { setLog } from './utils.js';
import { DETECTOR_OPTIONS } from './config.js';
import { overlayModeNeedsDetailedFaceapi, view as overlayView } from './bbox-overlay.js';
import { computeCompositeMetrics, decideMatchState } from './landmark-analysis.js';
import { t } from './i18n.js';
export { seekFaceInDb, computeCompositeMetrics, decideMatchState } from './landmark-analysis.js';
/**
* Detect a face in the webcam video and optionally draw an overlay.
* Returns the face detection result or null if no face is found.
* @param {boolean} drawOverlay - Whether to draw the detection overlay.
* @returns {Promise<Object|null>} Detection result, faceapi object.
* @see saveFace - uses detectFaceInCam before saving a face.
* @see findFace - uses detectFaceInCam to compare against stored faces.
* @see computeCompositeMetrics - uses detectFaceInCam as the baseline detection.
*/
export async function detectFaceInCam(drawOverlay) {
clearOverlay();
try {
if (!faceapi || !faceapi.detectSingleFace) {
setLog(t('face_api_models_not_loaded_log'));
state.lastKnownEffectResult = null;
return null;
}
const result = await faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS)
.withFaceLandmarks()
.withAgeAndGender()
.withFaceDescriptor();
if (!result) {
state.lastKnownEffectResult = null;
setLog(t('no_face_webcam_log'));
return null;
}
if (drawOverlay) drawResult(result);
return result;
} catch (err) {
console.error(t('console_detection_error'), err);
const msg = err?.message || String(err);
setLog(t('face_api_error_log', { message: msg }));
state.lastKnownEffectResult = null;
return null;
}
}
export function triggerOverlayFadeout() {
els.overlay.style.transition = 'none';
els.overlay.style.opacity = '1';
void els.overlay.offsetHeight; // force reflow
els.overlay.style.transition = 'opacity 2s ease-in-out';
if (state.overlayFadeTimeout) clearTimeout(state.overlayFadeTimeout);
state.overlayFadeTimeout = setTimeout(() => {
els.overlay.style.opacity = '0';
}, 5000);
}
/**
* Build a canvas with video compositing and the active 2D/3D Ghostyle overlay.
* Calls the active 2D Ghostyle's onDraw() hook to render the overlay.
* Executes face-api detection on the composited frame.
* Executes a Face API detection with landmarks and descriptor on the composite.
* Returns an object containing the canvas, obfuscatedResult, and weakDetection flag.
* If detection with the normal threshold (`scoreThreshold: 0.5`) fails, it retries with a relaxed threshold (0.1) to still extract numeric metrics from the composite —
* useful as a "makeup efficacy indicator" even beyond the detection threshold.
* `weakDetection` indicates when a fallback detection was required.
* @param {Object} liveResult - Result from the live face detection.
* @returns {Promise<Object>} An object with canvas, obfuscatedResult, and weakDetection.
* @see findFace - uses this function to obtain a composite for post‑makeup comparison.
* @see computeCompositeMetrics - uses detectFaceInCam as the baseline detection.
*/
export async function compositeAndDetect(liveResult) {
const canvas = document.createElement('canvas');
canvas.width = els.overlay.width;
canvas.height = els.overlay.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(els.video, 0, 0, canvas.width, canvas.height);
const style = state.loadedGhostyles.get(state.activeEffect);
if (style && style.module.onDraw) {
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const resized = faceapi.resizeResults(liveResult, { width: canvas.width, height: canvas.height });
if (!resized.detection) {
console.log(t('console_resized_detection_missing'), resized);
} else {
style.module.onDraw(ctx, resized.landmarks, resized.detection.box);
ctx.restore();
}
}
state.gstmxxEvents.dispatchEvent(new CustomEvent('beforeEfficacyComposite', {
detail: { canvas, ctx, liveResult }
}));
try {
let obfuscatedResult = await faceapi.detectSingleFace(canvas, DETECTOR_OPTIONS)
.withFaceLandmarks()
.withFaceDescriptor();
let weakDetection = false;
if (!obfuscatedResult) {
const weakOpts = new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.1 });
obfuscatedResult = await faceapi.detectSingleFace(canvas, weakOpts)
.withFaceLandmarks()
.withFaceDescriptor();
weakDetection = !!obfuscatedResult;
}
return { canvas, obfuscatedResult, weakDetection };
} catch (err) {
console.error(t('console_composite_detection_error'), err);
return { canvas, obfuscatedResult: null, weakDetection: false };
}
}
/**
* Run a single effect pass: performs face detection (with optional landmarks) and draws the effect overlay.
* Manages state flags to avoid concurrent inference.
* @returns {Promise<boolean>} Whether the overlay should be cleared (no face detected without active effect).
* @see drawGhostyleOverlay - invoked to render the effect.
* @see detectFaceInCam - used internally for detection when an active effect is present.
*/
export async function runEffectPass() {
if (state.isSystemBusy || state.effectInferenceInFlight || els.video.readyState < 2) return;
state.effectInferenceInFlight = true;
let retToCleanOverlay = false; // do not clean except if no face detected and no active effect, otherwise keep last overlay
try {
if (!faceapi || !faceapi.detectSingleFace) return;
const detector = faceapi.detectSingleFace(els.video, DETECTOR_OPTIONS);
let result = null;
if (state.activeEffect) {
result = await detector.withFaceLandmarks();
} else if (overlayModeNeedsDetailedFaceapi(overlayView.overlayMode)) {
result = await detector.withFaceLandmarks().withAgeAndGender();
} else {
result = await detector;
}
if (!result) {
state.lastKnownEffectResult = null;
if (state.activeEffect)
retToCleanOverlay = true;
} else if (state.activeEffect) {
drawGhostyleOverlay(result, false);
} else {
state.lastKnownEffectResult = result;
}
state.gstmxxEvents.dispatchEvent(new CustomEvent('detection', {
detail: { result: result || null, activeEffect: state.activeEffect }
}));
} catch (err) {
console.error(err);
} finally {
state.effectInferenceInFlight = false;
}
return retToCleanOverlay;
}
/**
* Draw the effect overlay onto the canvas, optionally including the detection scaffold.
* Resizes the canvas, clears previous drawings, and renders the active effect style if present.
* @param {Object} result - Face detection result.
* @param {boolean} [includeDetectionScaffold=false] - Whether to draw the detection scaffold.
* @see runEffectPass - calls this to render overlay after detection.
* @see drawDetectionScaffold - optionally used when includeDetectionScaffold is true.
*/
export function drawGhostyleOverlay(result, includeDetectionScaffold = false) {
resizeCanvas(els);
const ctx = els.overlay.getContext('2d');
ctx.clearRect(0, 0, els.overlay.width, els.overlay.height);
const resized = faceapi.resizeResults(result, { width: els.overlay.width, height: els.overlay.height });
if (!resized.detection) {
// console.log("drawGhostyleOverlay: no detection?", resized);
// added because sometimes this is undefined?
return;
}
if (includeDetectionScaffold) drawDetectionScaffold(resized);
if (state.activeEffect) {
const style = state.loadedGhostyles.get(state.activeEffect);
if (style && style.module.onDraw) {
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
style.module.onDraw(ctx, resized.landmarks, resized.detection.box);
ctx.restore();
}
}
state.lastKnownEffectResult = result;
}
/**
* Draw visual scaffolding for a detection result: bounding box, eye line, and facial landmarks.
* Useful for debugging and user feedback.
* @param {CanvasRenderingContext2D} ctx - Canvas context to draw on.
* @param {Object} resized - Resized detection result containing box and landmarks.
* @see drawGhostyleOverlay - may call this when includeDetectionScaffold is true.
* @see drawResult - calls this to display detection scaffold.
*/
export function drawDetectionScaffold(ctx, resized) {
const box = resized.detection.box;
const landmarks = resized.landmarks;
const leftEye = landmarks.getLeftEye();
const rightEye = landmarks.getRightEye();
const nose = landmarks.getNose();
const jaw = landmarks.getJawOutline();
const mouth = landmarks.getMouth();
ctx.save();
ctx.lineWidth = 2.2;
ctx.strokeStyle = 'rgba(122, 162, 255, 0.95)';
ctx.strokeRect(box.x, box.y, box.width, box.height);
const leftCenter = avgPoint(leftEye);
const rightCenter = avgPoint(rightEye);
ctx.beginPath();
ctx.moveTo(leftCenter.x, leftCenter.y);
ctx.lineTo(rightCenter.x, rightCenter.y);
ctx.stroke();
ctx.strokeStyle = 'rgba(255, 122, 122, 0.85)';
drawClosedPath(ctx, leftEye, null, 'rgba(255, 122, 122, 0.85)', 2);
drawClosedPath(ctx, rightEye, null, 'rgba(255, 122, 122, 0.85)', 2);
ctx.strokeStyle = 'rgba(159, 122, 234, 0.88)';
drawOpenPath(ctx, jaw, 'rgba(159, 122, 234, 0.88)', 2);
ctx.strokeStyle = 'rgba(61, 220, 151, 0.88)';
drawOpenPath(ctx, nose, 'rgba(61, 220, 151, 0.88)', 2);
ctx.strokeStyle = 'rgba(255, 204, 102, 0.88)';
drawClosedPath(ctx, mouth, null, 'rgba(255, 204, 102, 0.88)', 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.92)';
[leftCenter, rightCenter, avgPoint(nose.slice(3)), avgPoint(mouth.slice(0, 7))].forEach(pt => {
ctx.beginPath();
ctx.arc(pt.x, pt.y, 3.4, 0, Math.PI * 2);
ctx.fill();
});
const lines = ['volto rilevato'];
if (typeof resized.age === 'number') lines.push(`eta stimata: ${Math.round(resized.age)}`);
if (resized.gender) lines.push(`genere stimato: ${resized.gender}`);
ctx.font = '14px Inter, system-ui, sans-serif';
const pad = 6;
const lineHeight = 18;
const maxWidth = Math.max(...lines.map(l => ctx.measureText(l).width));
const boxWidth = maxWidth + pad * 2;
const boxHeight = lines.length * lineHeight + pad * 2;
const startX = box.x;
const startY = Math.max(16, box.y - boxHeight - 8);
if (state.isMirrored) {
ctx.translate(startX + boxWidth / 2, startY + boxHeight / 2);
ctx.scale(-1, 1);
ctx.translate(-(startX + boxWidth / 2), -(startY + boxHeight / 2));
}
ctx.fillStyle = 'rgba(15, 17, 21, 0.78)';
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.lineWidth = 1;
roundRect(ctx, startX, startY, boxWidth, boxHeight, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = 'rgba(238, 242, 255, 0.96)';
lines.forEach((line, i) => {
ctx.fillText(line, startX + pad, startY + pad + (i + 1) * lineHeight - 4);
});
ctx.restore();
}
/**
* Draw the detection result on the overlay canvas, including the detection scaffold and any active effect.
* @param {Object} result - Detection result.
* @see drawDetectionScaffold - used to draw scaffold.
* @see drawGhostyleOverlay - effect drawing is performed here if active.
*/
export function drawResult(result) {
resizeCanvas(els);
const ctx = els.overlay.getContext('2d');
ctx.clearRect(0, 0, els.overlay.width, els.overlay.height);
const resized = faceapi.resizeResults(result, { width: els.overlay.width, height: els.overlay.height });
drawDetectionScaffold(ctx, resized);
if (state.activeEffect) {
const style = state.loadedGhostyles.get(state.activeEffect);
if (style && style.module.onDraw) {
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
style.module.onDraw(ctx, resized.landmarks, resized.detection.box);
ctx.restore();
}
}
state.lastKnownEffectResult = result;
}
/**
* Capture the current face, save its descriptor and metadata to the local database, and log the action.
* @see detectFaceInCam - obtains the face data to be saved.
*/
export async function saveFace() {
const result = await detectFaceInCam(true);
if (!result) return;
triggerOverlayFadeout();
const id = state.db.nextId;
state.db.nextId += 1;
state.db.faces.push({
id,
descriptor: Array.from(result.descriptor),
landmarks: result.landmarks?.positions
? result.landmarks.positions.map((p) => ({ x: p.x, y: p.y }))
: null,
age: Math.round(result.age),
gender: result.gender || null,
savedAt: new Date().toISOString()
});
persistDb();
renderDbStats();
const score = result.detection.score;
setLog(t('face_saved_log', { id, score: score.toFixed(2) }));
return { id, result };
}
// This function shares the helper that are private, and so it can be
// used by the auto-loop-search-face
export function evaluateMatch(liveInfo, composite) {
const { liveMinDist, liveMinId } = liveInfo;
// here some boolean are computed to help the generation of color/message
const m = composite ? computeCompositeMetrics(composite) : {
obfScore: null,
obfMinDist: null,
obfMinId: null,
weakDetection: false,
detectionTotallyFailed: false
};
const { detectionState, headline, distance, matchedId } = decideMatchState({
liveMinDist,
liveMinId,
...m,
});
return {
headline,
detail: {
detectionState,
distance,
matchedId,
ghostylePresent: !!composite,
liveMinDist,
liveMinId,
obfMinDist: m.obfMinDist,
obfMinId: m.obfMinId,
},
};
}
/**
* Determine whether a 2D or 3D effect plugin is currently active.
* @returns {boolean} True if an effect plugin is active.
* @see findFace - checks plugin status before compositing.
*/
export function hasActivePlugin() {
const G = window.gstmxx;
const a2d = typeof G.getActiveEffect === 'function' && G.getActiveEffect();
const a3d = typeof G.getActiveEffect3d === 'function' && G.getActiveEffect3d();
return !!(a2d || a3d);
// state.activeEffect = string with the effect name
}