lab-js/realtime/realtime-guide.jsjavascript
/**
 * @module realtime-guide
 * @description Draws the baseline's normalized face geometry over the video.
 * The overlay is a positioning memory aid, not a mask or a measurement.
 */

const JAW = Array.from({ length: 17 }, (_, index) => index);
const LEFT_EYE = [36, 37, 38, 39, 40, 41];
const RIGHT_EYE = [42, 43, 44, 45, 46, 47];

/** True when Space belongs to the page rather than a focused control. */
export function canUseGuideShortcut(target) {
  if (!(target instanceof Element)) return true;
  if (target.closest('button, input, select, textarea, summary, video, [contenteditable="true"], [role="dialog"]')) {
    return false;
  }
  return true;
}

/**
 * Creates a temporary baseline-guide overlay.
 *
 * @param {object} options Controller wiring.
 * @param {HTMLCanvasElement} options.canvasEl Overlay canvas.
 * @param {HTMLVideoElement} options.videoEl Video whose object-fit is mirrored.
 * @param {HTMLElement} options.stageEl Canvas positioning container.
 * @returns {object} Guide controller.
 */
export function createBaselineGuide({ canvasEl, videoEl, stageEl }) {
  const ctx = canvasEl.getContext('2d');
  let guide = null;
  let fadeTimer = null;
  let hideTimer = null;

  function clearTimers() {
    if (fadeTimer) window.clearTimeout(fadeTimer);
    if (hideTimer) window.clearTimeout(hideTimer);
    fadeTimer = null;
    hideTimer = null;
  }

  function pointMapper(width, height) {
    const stageRect = stageEl.getBoundingClientRect();
    const videoRect = videoEl.getBoundingClientRect();
    const sourceW = guide.aspectRatio;
    const sourceH = 1;
    const fit = getComputedStyle(videoEl).objectFit || 'fill';
    const scale = fit === 'cover'
      ? Math.max(videoRect.width / sourceW, videoRect.height / sourceH)
      : Math.min(videoRect.width / sourceW, videoRect.height / sourceH);
    const renderedW = sourceW * scale;
    const renderedH = sourceH * scale;
    const offsetX = videoRect.left - stageRect.left + (videoRect.width - renderedW) / 2;
    const offsetY = videoRect.top - stageRect.top + (videoRect.height - renderedH) / 2;
    const mirrored = videoEl.classList.contains('source-camera');
    return ({ x, y }) => ({
      x: mirrored ? width - (offsetX + x * sourceW * scale) : offsetX + x * sourceW * scale,
      y: Math.min(height, Math.max(0, offsetY + y * sourceH * scale)),
    });
  }

  function strokePath(points, close = false) {
    if (!points.length) return;
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for (let index = 1; index < points.length; index += 1) ctx.lineTo(points[index].x, points[index].y);
    if (close) ctx.closePath();
    ctx.stroke();
  }

  /** Paints the guide using the current stage geometry. */
  function draw() {
    const rect = stageEl.getBoundingClientRect();
    const width = Math.max(1, Math.floor(rect.width));
    const height = Math.max(1, Math.floor(rect.height));
    const dpr = Math.max(1, window.devicePixelRatio || 1);
    canvasEl.width = Math.floor(width * dpr);
    canvasEl.height = Math.floor(height * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, width, height);
    if (!guide) return;

    const map = pointMapper(width, height);
    const landmarks = guide.landmarks.map(map);
    const box = guide.box;
    const boxTopLeft = map({ x: box.x, y: box.y });
    const boxTopRight = map({ x: box.x + box.width, y: box.y });
    const jaw = JAW.map((index) => landmarks[index]);
    const leftEye = LEFT_EYE.map((index) => landmarks[index]);
    const rightEye = RIGHT_EYE.map((index) => landmarks[index]);
    const nose = landmarks[30];

    ctx.strokeStyle = getComputedStyle(canvasEl).getPropertyValue('--rt-guide').trim() || '#ffe7a8';
    ctx.fillStyle = ctx.strokeStyle;
    ctx.lineWidth = 1.5;
    ctx.setLineDash([6, 5]);
    strokePath(jaw);

    // face-api has no scalp landmarks. The upper reference is therefore an
    // explicit detector-box arc, connected to the jaw endpoints.
    const leftJaw = jaw[0];
    const rightJaw = jaw[jaw.length - 1];
    const topY = Math.min(boxTopLeft.y, boxTopRight.y);
    const centerX = (leftJaw.x + rightJaw.x) / 2;
    const radiusX = Math.abs(rightJaw.x - leftJaw.x) / 2;
    const radiusY = Math.max(12, Math.max(leftJaw.y, rightJaw.y) - topY);
    ctx.beginPath();
    ctx.ellipse(centerX, Math.max(leftJaw.y, rightJaw.y), radiusX, radiusY, 0, Math.PI, Math.PI * 2);
    ctx.stroke();

    ctx.setLineDash([]);
    strokePath(leftEye, true);
    strokePath(rightEye, true);
    const eyeA = landmarks[36];
    const eyeB = landmarks[45];
    strokePath([eyeA, eyeB]);
    ctx.beginPath();
    ctx.arc(nose.x, nose.y, 3.5, 0, Math.PI * 2);
    ctx.fill();
  }

  function hide() {
    clearTimers();
    canvasEl.classList.remove('is-visible', 'is-fading');
  }

  function show() {
    if (!guide) return false;
    clearTimers();
    draw();
    canvasEl.classList.remove('is-fading');
    canvasEl.classList.add('is-visible');
    fadeTimer = window.setTimeout(() => canvasEl.classList.add('is-fading'), 1000);
    hideTimer = window.setTimeout(hide, 1600);
    return true;
  }

  const observer = new ResizeObserver(() => {
    if (canvasEl.classList.contains('is-visible')) draw();
  });
  observer.observe(stageEl);

  return {
    draw,
    show,
    hide,
    setGuide(value) {
      guide = value || null;
      if (!guide) hide();
    },
    hasGuide() {
      return Boolean(guide);
    },
    destroy() {
      clearTimers();
      observer.disconnect();
    },
  };
}