lab-js/realtime/realtime-capture.jsjavascript
/**
 * @module realtime-capture
 * @description
 * Everything that decides whether a frame is good enough to believe.
 *
 * The baseline is captured once and every number for the rest of the session
 * is measured against it, so it is worth being slow and picky about. The
 * prototype took the first frame that returned a descriptor — no confidence
 * check, no framing check, no pose check — which meant a blurred half-turned
 * frame could silently become the reference for an hour of work.
 *
 * The same gate is reused for spike captures, for the same reason: a single
 * frame at 0.50 is as likely to be motion blur as a result, and "the makeup is
 * working" is not a claim you want resting on a photo of someone moving.
 */

/** Landmark indices in face-api's 68-point model. */
const LM_LEFT_EYE_OUTER = 36;
const LM_RIGHT_EYE_OUTER = 45;
const LM_NOSE_TIP = 30;

const DEFAULT_GATE = {
  minScore: 0.85,
  minBoxWidthRatio: 0.25,
  edgeMarginRatio: 0.02,
  maxRollDegrees: 8,
  maxYawOffset: 0.12,
  minLuma: 0.18,
  maxLuma: 0.86,
  maxClippedRatio: 0.02,
};

const DEFAULT_COMPARISON = {
  maxRollDelta: 4,
  maxYawDelta: 0.055,
  maxScaleDelta: 0.08,
  maxCenterDelta: 0.065,
  maxLumaDelta: 0.1,
};

const CONDITION_KEYS = [
  'rollDegrees',
  'yawOffset',
  'boxWidthRatio',
  'centerXRatio',
  'centerYRatio',
  'luma',
];

/**
 * Averages the capture conditions accepted during one temporal window.
 *
 * @param {object[]} readings Per-frame 2D pose, framing and light metrics.
 * @returns {object|null} Mean condition profile, or null when unavailable.
 */
export function summarizeConditions(readings) {
  const usable = readings.filter(Boolean);
  if (!usable.length) return null;
  const profile = {};
  for (const key of CONDITION_KEYS) {
    const values = usable.map((reading) => reading[key]).filter(Number.isFinite);
    if (values.length) profile[key] = values.reduce((sum, value) => sum + value, 0) / values.length;
  }
  return Object.keys(profile).length ? profile : null;
}

/** Returns the median finite value from a list, or zero for an empty list. */
function median(values) {
  const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
  if (!sorted.length) return 0;
  const middle = Math.floor(sorted.length / 2);
  return sorted.length % 2
    ? sorted[middle]
    : (sorted[middle - 1] + sorted[middle]) / 2;
}

/**
 * Builds one robust, normalized 2D reference guide from accepted frames.
 * This geometry is deliberately separate from the descriptor medoid: it is
 * only a visual alignment aid and never participates in distance arithmetic.
 *
 * @param {object[]} guides Per-frame normalized face boxes and 68 landmarks.
 * @returns {object|null} Coordinate-wise median guide.
 */
export function summarizeGuides(guides) {
  const usable = guides.filter((guide) =>
    guide &&
    Number.isFinite(guide.aspectRatio) &&
    Array.isArray(guide.landmarks) &&
    guide.landmarks.length === 68,
  );
  if (!usable.length) return null;
  const coordinate = (read) => median(usable.map(read));
  return {
    aspectRatio: coordinate((guide) => guide.aspectRatio),
    box: {
      x: coordinate((guide) => guide.box.x),
      y: coordinate((guide) => guide.box.y),
      width: coordinate((guide) => guide.box.width),
      height: coordinate((guide) => guide.box.height),
    },
    landmarks: Array.from({ length: 68 }, (_, index) => ({
      x: coordinate((guide) => guide.landmarks[index].x),
      y: coordinate((guide) => guide.landmarks[index].y),
    })),
  };
}

/**
 * Checks whether a live frame is comparable with the baseline conditions.
 * This is deliberately a 2D nuisance gate, not pose correction: rejected
 * frames are omitted rather than mathematically transformed into a result.
 *
 * @param {object|null} baseline Baseline condition profile.
 * @param {object|null} current Current frame conditions.
 * @param {object} [overrides] Partial tolerance overrides.
 * @returns {{ok: boolean, reason: string, reasons: string[], differences: object}} Comparison verdict.
 */
export function compareConditions(baseline, current, overrides = {}) {
  if (!baseline || !current) return { ok: true, reason: '', reasons: [], differences: {} };
  const limits = { ...DEFAULT_COMPARISON, ...overrides };
  const difference = (key) => Math.abs((current[key] ?? 0) - (baseline[key] ?? 0));
  const differences = Object.fromEntries(CONDITION_KEYS.map((key) => [key, difference(key)]));
  const reasons = [];

  if (
    differences.rollDegrees > limits.maxRollDelta ||
    differences.yawOffset > limits.maxYawDelta
  ) {
    const details = [];
    if (differences.rollDegrees > limits.maxRollDelta) {
      details.push(`roll Δ${differences.rollDegrees.toFixed(1)}°; limit ${limits.maxRollDelta}°`);
    }
    if (differences.yawOffset > limits.maxYawDelta) {
      details.push(
        `yaw proxy Δ${differences.yawOffset.toFixed(3)}; limit ${limits.maxYawDelta.toFixed(3)}`,
      );
    }
    reasons.push(`Head angle differs from the baseline pose (${details.join(', ')}).`);
  }
  if (differences.boxWidthRatio > limits.maxScaleDelta) {
    reasons.push(
      current.boxWidthRatio > baseline.boxWidthRatio
        ? 'Face size differs from the baseline framing. Move slightly farther from the camera.'
        : 'Face size differs from the baseline framing. Move slightly closer to the camera.',
    );
  }
  if (
    differences.centerXRatio > limits.maxCenterDelta ||
    differences.centerYRatio > limits.maxCenterDelta
  ) {
    reasons.push('Face position differs from the baseline framing. Re-centre it in the frame.');
  }
  if (differences.luma > limits.maxLumaDelta) {
    reasons.push(
      `Room lighting differs from the baseline (luma Δ${differences.luma.toFixed(2)}; limit ${limits.maxLumaDelta.toFixed(2)}).`,
    );
  }
  if (reasons.length) {
    return { ok: false, reason: reasons[0], reasons, differences };
  }
  return { ok: true, reason: '', reasons: [], differences };
}

/**
 * Euclidean distance between two descriptors.
 *
 * Kept local rather than reaching for the global `faceapi` so this module can
 * be imported and unit tested without the vendor bundle present.
 *
 * @param {Float32Array|number[]} a First descriptor.
 * @param {Float32Array|number[]} b Second descriptor.
 * @returns {number} Euclidean distance.
 */
export function euclidean(a, b) {
  let sum = 0;
  for (let i = 0; i < a.length; i += 1) {
    const d = a[i] - b[i];
    sum += d * d;
  }
  return Math.sqrt(sum);
}

/**
 * Picks the medoid of a descriptor set and reports its spread.
 *
 * The medoid — the member with the smallest summed distance to the others — is
 * used rather than the mean because it stays a real point in the embedding.
 * Averaging near-unit vectors that are not perfectly aligned yields something
 * slightly shorter than its inputs, which bakes a small systematic offset into
 * every distance measured for the rest of the session.
 *
 * The returned spread is the session's NOISE FLOOR: how far the descriptor
 * moves when nothing about the face has changed. It is the only empirical
 * reference line this tool draws.
 *
 * @param {Array<Float32Array|number[]>} descriptors Collected descriptors.
 * @returns {{descriptor: number[], spread: number, index: number}} Medoid result.
 */
export function medoid(descriptors) {
  if (!descriptors.length) throw new Error('medoid() needs at least one descriptor');
  if (descriptors.length === 1) {
    return { descriptor: Array.from(descriptors[0]), spread: 0, index: 0 };
  }

  const n = descriptors.length;
  const matrix = Array.from({ length: n }, () => new Float64Array(n));

  for (let i = 0; i < n; i += 1) {
    for (let j = i + 1; j < n; j += 1) {
      const d = euclidean(descriptors[i], descriptors[j]);
      matrix[i][j] = d;
      matrix[j][i] = d;
    }
  }

  let bestIndex = 0;
  let bestSum = Infinity;
  for (let i = 0; i < n; i += 1) {
    let sum = 0;
    for (let j = 0; j < n; j += 1) sum += matrix[i][j];
    if (sum < bestSum) {
      bestSum = sum;
      bestIndex = i;
    }
  }

  const spread = bestSum / (n - 1);
  return { descriptor: Array.from(descriptors[bestIndex]), spread, index: bestIndex };
}

/**
 * Creates a reusable scratch canvas for crop analysis.
 *
 * @param {number} size Square edge length in pixels.
 * @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D}} Scratch surface.
 */
function createScratch(size) {
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  return { canvas, ctx: canvas.getContext('2d', { willReadFrequently: true }) };
}

/**
 * Measures relative sharpness and exposure of a face crop.
 *
 * Sharpness is gradient energy and is used only for RANKING candidates against
 * each other, never against a fixed floor — an absolute threshold would be one
 * more magic number that varies with every camera and lighting setup.
 *
 * @param {HTMLVideoElement} video Source video element.
 * @param {{x: number, y: number, width: number, height: number}} box Face box in video pixels.
 * @param {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D}} scratch Scratch surface.
 * @returns {{sharpness: number, luma: number, clipped: number}} Crop statistics.
 */
function analyseCrop(video, box, scratch) {
  const size = scratch.canvas.width;
  scratch.ctx.drawImage(
    video,
    Math.max(0, box.x),
    Math.max(0, box.y),
    Math.max(1, box.width),
    Math.max(1, box.height),
    0,
    0,
    size,
    size,
  );

  const { data } = scratch.ctx.getImageData(0, 0, size, size);
  const gray = new Float32Array(size * size);
  let lumaSum = 0;
  let clipped = 0;

  for (let i = 0, p = 0; i < data.length; i += 4, p += 1) {
    const value = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) / 255;
    gray[p] = value;
    lumaSum += value;
    if (value > 0.98) clipped += 1;
  }

  let energy = 0;
  for (let y = 1; y < size - 1; y += 1) {
    for (let x = 1; x < size - 1; x += 1) {
      const idx = y * size + x;
      const gx = gray[idx + 1] - gray[idx - 1];
      const gy = gray[idx + size] - gray[idx - size];
      energy += gx * gx + gy * gy;
    }
  }

  const pixels = size * size;
  return {
    sharpness: energy / pixels,
    luma: lumaSum / pixels,
    clipped: clipped / pixels,
  };
}

/**
 * Estimates illumination from pixels outside the detected face box. Makeup can
 * legitimately darken the face crop; using the surrounding frame makes the
 * light comparison less likely to erase the very intervention being studied.
 *
 * @param {HTMLVideoElement} video Source video element.
 * @param {{x: number, y: number, width: number, height: number}} box Face box.
 * @param {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D}} scratch Scratch surface.
 * @returns {number} Mean normalized luma outside the face box.
 */
function analyseAmbientLuma(video, box, scratch) {
  const size = scratch.canvas.width;
  scratch.ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight, 0, 0, size, size);
  const { data } = scratch.ctx.getImageData(0, 0, size, size);
  const left = (box.x / video.videoWidth) * size;
  const right = ((box.x + box.width) / video.videoWidth) * size;
  const top = (box.y / video.videoHeight) * size;
  const bottom = ((box.y + box.height) / video.videoHeight) * size;
  let sum = 0;
  let count = 0;

  for (let y = 0; y < size; y += 1) {
    for (let x = 0; x < size; x += 1) {
      if (x >= left && x <= right && y >= top && y <= bottom) continue;
      const offset = (y * size + x) * 4;
      sum += (data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114) / 255;
      count += 1;
    }
  }
  return count ? sum / count : Number.NaN;
}

/**
 * Creates the frame quality gate.
 *
 * @param {HTMLVideoElement} video Source video element.
 * @param {object} [overrides] Partial gate threshold overrides.
 * @returns {{evaluate: Function}} Quality gate.
 */
export function createQualityGate(video, overrides = {}) {
  const limits = { ...DEFAULT_GATE, ...overrides };
  const scratch = createScratch(96);

  return {
    /**
     * Judges one detection against every framing and quality rule.
     *
     * @param {*} detection face-api detection with landmarks and descriptor.
     * @param {object} [options] Evaluation mode.
     * @param {boolean} [options.allowAppearanceChanges=false] Do not let face-crop
     *   exposure or detector-score changes reject an intervention frame.
     * @returns {{ok: boolean, reason: string, reasons: string[], code: string, sharpness: number}} Verdict.
     */
    evaluate(detection, options = {}) {
      const { allowAppearanceChanges = false } = options;
      const reject = (code, reason) => ({
        ok: false,
        code,
        reason,
        reasons: [reason],
        failures: [{ code, reason }],
        sharpness: 0,
      });

      const box = detection?.detection?.box;
      const score = detection?.detection?.score ?? 0;
      const points = detection?.landmarks?.positions;
      if (!box || !points) return reject('detection', 'No usable face detection.');

      const videoW = video.videoWidth || 1;
      const videoH = video.videoHeight || 1;
      const guide = {
        aspectRatio: videoW / videoH,
        box: {
          x: box.x / videoW,
          y: box.y / videoH,
          width: box.width / videoW,
          height: box.height / videoH,
        },
        landmarks: points.map((point) => ({ x: point.x / videoW, y: point.y / videoH })),
      };

      const failures = [];
      const fail = (code, reason) => failures.push({ code, reason });

      if (!allowAppearanceChanges && score < limits.minScore) {
        fail(
          'confidence',
          `Face detected, but confidence is low (${score.toFixed(2)}; target ${limits.minScore.toFixed(2)}). Hold still and check glare or shadows.`,
        );
      }
      if (box.width < videoW * limits.minBoxWidthRatio) {
        fail('scale', 'Face detected, but it is too small in the frame. Move closer.');
      }

      const marginX = videoW * limits.edgeMarginRatio;
      const marginY = videoH * limits.edgeMarginRatio;
      const clipsFrame =
        box.x < marginX ||
        box.y < marginY ||
        box.x + box.width > videoW - marginX ||
        box.y + box.height > videoH - marginY;
      if (clipsFrame) fail('framing', 'Face detected, but it is too close to an edge. Centre it in the frame.');

      const leftEye = points[LM_LEFT_EYE_OUTER];
      const rightEye = points[LM_RIGHT_EYE_OUTER];
      const nose = points[LM_NOSE_TIP];
      if (!leftEye || !rightEye || !nose) return reject('landmarks', 'Face detected, but its landmarks are incomplete.');

      const rollDegrees =
        (Math.atan2(rightEye.y - leftEye.y, rightEye.x - leftEye.x) * 180) / Math.PI;
      if (Math.abs(rollDegrees) > limits.maxRollDegrees) {
        fail(
          'roll',
          `Face detected, but the head is tilted (${Math.abs(rollDegrees).toFixed(1)}°; target ≤ ${limits.maxRollDegrees}°). Level it and hold still.`,
        );
      }

      const interocular = Math.hypot(rightEye.x - leftEye.x, rightEye.y - leftEye.y) || 1;
      const eyeMidX = (leftEye.x + rightEye.x) / 2;
      const yawOffset = (nose.x - eyeMidX) / interocular;
      if (Math.abs(yawOffset) > limits.maxYawOffset) {
        fail(
          'yaw',
          `Face detected, but the head is turned (yaw proxy ${Math.abs(yawOffset).toFixed(2)}; target ≤ ${limits.maxYawOffset.toFixed(2)}). Face the camera and hold still.`,
        );
      }

      const ambientLuma = analyseAmbientLuma(video, box, scratch);
      const crop = analyseCrop(video, box, scratch);
      if (!allowAppearanceChanges) {
        if (crop.luma < limits.minLuma) {
          fail('underexposed', 'Face detected, but the face crop is too dark. Add some light.');
        }
        if (crop.luma > limits.maxLuma || crop.clipped > limits.maxClippedRatio) {
          fail(
            'overexposed',
            `Face detected, but the face crop is overexposed (clipped ${(crop.clipped * 100).toFixed(1)}%; target ≤ ${(limits.maxClippedRatio * 100).toFixed(1)}%). Reduce glare or direct light.`,
          );
        }
      }

      const metrics = {
        rollDegrees,
        yawOffset,
        boxWidthRatio: box.width / videoW,
        centerXRatio: (box.x + box.width / 2) / videoW,
        centerYRatio: (box.y + box.height / 2) / videoH,
        luma: Number.isFinite(ambientLuma) ? ambientLuma : crop.luma,
        faceLuma: crop.luma,
        clippedRatio: crop.clipped,
        detectorScore: score,
      };

      if (failures.length) {
        return {
          ok: false,
          code: failures[0].code,
          reason: failures[0].reason,
          reasons: failures.map((failure) => failure.reason),
          failures,
          sharpness: crop.sharpness,
          metrics,
          guide,
        };
      }

      return {
        ok: true,
        code: '',
        reason: '',
        reasons: [],
        failures: [],
        sharpness: crop.sharpness,
        metrics,
        guide,
      };
    },
  };
}

/**
 * Creates the baseline collector.
 *
 * Progress advances on ACCEPTED samples only, never on elapsed time. A stalled
 * ring means frames are being rejected, and the reason is reported alongside —
 * so the control diagnoses itself instead of silently timing out.
 *
 * @param {object} options Collector options.
 * @param {number} options.targetSamples Accepted samples required.
 * @param {number} options.minDurationMs Floor on capture duration.
 * @param {number} options.timeoutMs Abandon threshold.
 * @param {number} options.maxSpread Largest acceptable noise floor.
 * @returns {object} Baseline collector.
 */
export function createBaselineCollector(options) {
  const { targetSamples, minDurationMs, timeoutMs, maxSpread } = options;

  let descriptors = [];
  let conditions = [];
  let guides = [];
  let startedAt = 0;
  let lastAcceptedAt = 0;
  let firstAcceptedAt = 0;
  let lastObservationId = null;
  let attempted = 0;
  let rejected = 0;
  let duplicates = 0;
  let active = false;

  return {
    /**
     * Begins a fresh collection run.
     *
     * @returns {void}
     */
    start() {
      descriptors = [];
      conditions = [];
      guides = [];
      startedAt = Date.now();
      lastAcceptedAt = 0;
      firstAcceptedAt = 0;
      lastObservationId = null;
      attempted = 0;
      rejected = 0;
      duplicates = 0;
      active = true;
    },

    /**
     * Ends the current run without producing a baseline.
     *
     * @returns {void}
     */
    cancel() {
      active = false;
      descriptors = [];
      conditions = [];
      guides = [];
      lastAcceptedAt = 0;
      firstAcceptedAt = 0;
      lastObservationId = null;
      attempted = 0;
      rejected = 0;
      duplicates = 0;
    },

    /**
     * Reports whether a run is in progress.
     *
     * @returns {boolean} True while collecting.
     */
    isActive() {
      return active;
    },

    /**
     * Offers one gated frame to the collector.
     *
     * @param {object} verdict Quality gate verdict.
     * @param {Float32Array|number[]} descriptor Descriptor for the frame.
     * @param {object} [observation] Source timing and identity.
     * @param {number} [observation.sourceTimeMs] Media time for a local video.
     * @param {string|number} [observation.id] Stable id for one source frame.
     * @returns {object} Collector progress and, when complete, the temporal medoid.
     */
    offer(verdict, descriptor, observation = {}) {
      if (!active) return { status: 'idle', accepted: 0, target: targetSamples };

      const now = Date.now();
      const elapsed = now - startedAt;
      attempted += 1;

      const progress = (status, extra = {}) => ({
        status,
        accepted: descriptors.length,
        target: targetSamples,
        attempted,
        rejected,
        duplicates,
        ...extra,
      });

      if (!verdict.ok) {
        rejected += 1;
        if (elapsed > timeoutMs) {
          active = false;
          return progress('failed', {
            reason: `Not enough clean frames before the capture timed out. Last rejection: ${verdict.reason}`,
            reasons: verdict.reasons || [verdict.reason],
          });
        }
        return progress('blocked', {
          reason: verdict.reason,
          reasons: verdict.reasons || [verdict.reason],
        });
      }

      if (observation.id != null && observation.id === lastObservationId) {
        duplicates += 1;
        if (elapsed > timeoutMs) {
          active = false;
          return progress('failed', {
            reason: 'The video stopped advancing before enough clean frames were collected. Play or seek the video and try again.',
          });
        }
        return progress('waiting', { reason: 'Waiting for the video to advance to a new frame.' });
      }
      if (observation.id != null) lastObservationId = observation.id;

      const sampleTime = Number.isFinite(observation.sourceTimeMs)
        ? observation.sourceTimeMs
        : now;

      // Space observations across the whole requested window. Without this,
      // a fast detector could fill the array in one short burst and merely
      // wait out minDurationMs without observing the rest of the interval.
      const sampleIntervalMs = targetSamples > 1 ? minDurationMs / (targetSamples - 1) : 0;
      const sampleDue = descriptors.length === 0 || sampleTime - lastAcceptedAt >= sampleIntervalMs;
      if (descriptors.length < targetSamples && sampleDue) {
        descriptors.push(Array.from(descriptor));
        if (verdict.metrics) conditions.push(verdict.metrics);
        if (verdict.guide) guides.push(verdict.guide);
        lastAcceptedAt = sampleTime;
        if (descriptors.length === 1) firstAcceptedAt = sampleTime;
      }

      const enoughSamples = descriptors.length >= targetSamples;
      const enoughTime =
        descriptors.length > 0 && lastAcceptedAt - firstAcceptedAt >= minDurationMs;

      if (!enoughSamples || !enoughTime) {
        if (elapsed > timeoutMs) {
          active = false;
          return progress('failed', {
            reason: 'Not enough clean frames before the capture timed out.',
          });
        }
        return progress('collecting');
      }

      const result = medoid(descriptors);
      active = false;

      // A spread this wide means the subject moved or the lighting flickered
      // during collection. Accepting it would set a noise floor so high that
      // real results disappear underneath it.
      if (result.spread > maxSpread) {
        return progress('failed', {
          reason: 'Too much movement during capture.',
        });
      }

      return progress('done', {
        baseline: {
          descriptor: result.descriptor,
          noiseFloor: result.spread,
          capturedAt: Date.now(),
          samples: descriptors.length,
          conditions: summarizeConditions(conditions),
          guide: summarizeGuides(guides),
        },
      });
    },
  };
}

/**
 * Creates the sustained-spike detector.
 *
 * Capture triggers on a reading that HOLDS, not on an instantaneous value, and
 * the frame written to disk is the sharpest one observed across the sustain
 * window rather than whichever frame happened to trip the trigger.
 *
 * Two conditions fire a capture:
 *   personal best — a new session maximum by a real margin, which is what you
 *                   iterate against between makeup passes
 *   crossing      — the first time the run clears the cited match threshold
 *
 * @param {object} options Detector options.
 * @param {number} options.sustainMs Time a reading must hold.
 * @param {number} options.bestMargin Margin over the session best.
 * @param {number} options.threshold Cited match threshold.
 * @param {number} options.cooldownMs Minimum spacing between captures.
 * @returns {object} Spike detector.
 */
export function createSpikeDetector(options) {
  const { sustainMs, bestMargin, threshold, cooldownMs } = options;

  let sessionBest = 0;
  let crossedThreshold = false;
  let runStartedAt = 0;
  let runValueFloor = 0;
  let lastCaptureAt = 0;

  return {
    /**
     * Clears session state after a new baseline is set.
     *
     * @returns {void}
     */
    reset() {
      sessionBest = 0;
      crossedThreshold = false;
      runStartedAt = 0;
      runValueFloor = 0;
      lastCaptureAt = 0;
    },

    /**
     * Marks a capture as taken so the cooldown applies.
     *
     * @param {number} value Distance at capture time.
     * @returns {void}
     */
    noteCapture(value) {
      lastCaptureAt = Date.now();
      sessionBest = Math.max(sessionBest, value);
      if (value >= threshold) crossedThreshold = true;
      runStartedAt = 0;
    },

    /**
     * Feeds one live distance reading to the detector.
     *
     * @param {number} value Live distance for this frame.
     * @returns {{capture: boolean, kind: string, candidate: boolean}} Verdict.
     */
    update(value) {
      const now = Date.now();
      const beatsBest = value >= sessionBest + bestMargin;
      const crosses = !crossedThreshold && value >= threshold;
      const interesting = beatsBest || crosses;

      if (!interesting) {
        runStartedAt = 0;
        return { capture: false, kind: '', candidate: false };
      }

      if (!runStartedAt) {
        runStartedAt = now;
        runValueFloor = value;
        return { capture: false, kind: '', candidate: true };
      }

      // A dip below where the run started means the reading did not hold.
      if (value < runValueFloor - bestMargin) {
        runStartedAt = 0;
        return { capture: false, kind: '', candidate: false };
      }

      if (now - runStartedAt < sustainMs) {
        return { capture: false, kind: '', candidate: true };
      }

      if (now - lastCaptureAt < cooldownMs) {
        return { capture: false, kind: '', candidate: true };
      }

      return { capture: true, kind: crosses ? 'crossing' : 'best', candidate: true };
    },
  };
}

/**
 * Creates a rotating buffer of full video frames.
 *
 * Snapshots are only taken while a spike candidate is live, so the buffer costs
 * nothing during ordinary tracking.
 *
 * @param {HTMLVideoElement} video Source video element.
 * @param {number} slots Number of frames retained.
 * @param {number} maxWidth Longest edge written to a snapshot.
 * @returns {object} Frame buffer.
 */
export function createFrameBuffer(video, slots, maxWidth) {
  const buffer = [];
  let cursor = 0;

  return {
    /**
     * Empties the buffer.
     *
     * @returns {void}
     */
    clear() {
      buffer.length = 0;
      cursor = 0;
    },

    /**
     * Records the current video frame with its measured sharpness.
     *
     * @param {number} sharpness Relative sharpness of this frame.
     * @param {number} distance Distance reading at this frame.
     * @returns {void}
     */
    push(sharpness, distance) {
      const videoW = video.videoWidth || 0;
      const videoH = video.videoHeight || 0;
      if (!videoW || !videoH) return;

      const scale = Math.min(1, maxWidth / videoW);
      const width = Math.round(videoW * scale);
      const height = Math.round(videoH * scale);

      let slot = buffer[cursor];
      if (!slot) {
        slot = { canvas: document.createElement('canvas'), sharpness: 0, distance: 0, t: 0 };
        buffer[cursor] = slot;
      }
      if (slot.canvas.width !== width || slot.canvas.height !== height) {
        slot.canvas.width = width;
        slot.canvas.height = height;
      }

      slot.canvas.getContext('2d').drawImage(video, 0, 0, width, height);
      slot.sharpness = sharpness;
      slot.distance = distance;
      slot.t = Date.now();
      cursor = (cursor + 1) % slots;
    },

    /**
     * Returns the sharpest retained frame.
     *
     * @returns {{canvas: HTMLCanvasElement, sharpness: number, distance: number, t: number}|null} Best frame.
     */
    best() {
      let winner = null;
      for (const slot of buffer) {
        if (slot && (!winner || slot.sharpness > winner.sharpness)) winner = slot;
      }
      return winner;
    },
  };
}