mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
O escopo A/C do gate (PR #1370) é superado por decisão do dono (2026-10-01, aval jurídico declarado): o índice vira anônimo (archivePhotoFace, foto + descritor 128-d, sem nome/vínculo), o enrollment por pessoa morre (faceSubject/faces:enroll removidos) e qualquer visitante encontra as próprias fotos aprovadas. - faces:index persiste os descritores por foto (transação substitui as linhas + marker = modelo); desaprovar/remover/deletar foto apaga os descritores (hooks + FK cascade) - POST /api/fotos/selfie casa o vetor da consulta contra o índice em memória (TTL 60 s) e devolve só a interseção com o read aprovado do C233, sem score/nome de terceiro; leave-index apaga os descritores que casarem - Consentimentos seguem fail-closed: busca-selfie-fotos (consulta) e busca-selfie-indice (aviso público do índice), textos versionados em scripts/lib/faceConsentTexts.mjs e provisionados por pnpm seed:face-consents - pnpm archive:publish aprova o acervo em lote (draft -> approved; removed nunca) com guardas e recibo - C243: seção condicional SelfieSearchHomeSection (port do design hi-fi, cenas 390/1280) entre a Central e a newsletter; sem engine/captura na home e sem CTA morto quando fechada - docs: planos C242/C243, AGENTS-public, runbook §C242, changelog Provas: gate:fast 457 arquivos/5.055 testes; int full 109/1.075; e2e frontendFotosSelfie 9/9; knip verde.
171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
/**
|
|
* C242 — pure contract of the selfie search over the public photo album
|
|
* (scope B: the anonymous descriptor index of the approved archive, decision
|
|
* of 2026-10-01, supersedes the A/C enrollment of C234).
|
|
*
|
|
* The visitor's face descriptor is computed on their own device (the selfie
|
|
* never leaves it); the server only ever sees the 128-float vector and answers
|
|
* with photos, never with a score. This module owns the wire vocabulary (the
|
|
* descriptor length, the model id, the intents), the distance math, the match
|
|
* over photo descriptors (which by SHAPE cannot carry a similarity number),
|
|
* the result view model (no third-party names) and the deterministic stub the
|
|
* e2e build uses in place of the engine.
|
|
*
|
|
* The anonymous photo descriptors live in `archivePhotoFace` (photo-level, no
|
|
* identity); a match never resolves a person — only photos. The visitor's
|
|
* vector is never persisted: it lives in the request only.
|
|
*/
|
|
import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
|
|
|
|
/** face-api's faceRecognitionNet descriptor (128 floats, euclidean space). */
|
|
export const FACE_SEARCH_DESCRIPTOR_LENGTH = 128
|
|
|
|
/** The engine+model the indexed vectors belong to — a match across models is void. */
|
|
export const FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'
|
|
|
|
/**
|
|
* Internal similarity threshold (face-api's usual 0.6 is a permissive default;
|
|
* the feature prefers missing a match over showing the wrong person). Never
|
|
* serialized: no caller of this contract receives a distance.
|
|
*/
|
|
export const FACE_SEARCH_MAX_DISTANCE = 0.45
|
|
|
|
/** Ceiling of photos in one answer; the oldest beyond it stay only in the album. */
|
|
export const FACE_SEARCH_RESULT_LIMIT = 60
|
|
|
|
/**
|
|
* Detector tuning shared by the browser engine and the CLI engine: they must
|
|
* load the same three nets with the same options, otherwise the descriptors of
|
|
* the selfie and of the archive would live in different spaces.
|
|
*/
|
|
export const FACE_DETECTOR_INPUT_SIZE = 512
|
|
export const FACE_DETECTOR_SCORE_THRESHOLD = 0.3
|
|
|
|
/** Same-origin directory the browser engine loads the model files from. */
|
|
export const FACE_MODEL_DIR = '/fotos/modelos'
|
|
|
|
export const FACE_SEARCH_INTENTS = ['search', 'leave-index'] as const
|
|
|
|
/**
|
|
* A stored/requested descriptor: exactly `FACE_SEARCH_DESCRIPTOR_LENGTH` finite
|
|
* numbers, or nothing (fail-closed — a wrong-length or poisoned vector never
|
|
* reaches the math).
|
|
*/
|
|
export const readFaceVector = (value: unknown): number[] | null => {
|
|
if (!Array.isArray(value) || value.length !== FACE_SEARCH_DESCRIPTOR_LENGTH) return null
|
|
|
|
const vector: number[] = []
|
|
for (const component of value) {
|
|
if (typeof component !== 'number' || !Number.isFinite(component)) return null
|
|
vector.push(component)
|
|
}
|
|
return vector
|
|
}
|
|
|
|
/**
|
|
* Euclidean distance of same-length descriptors; a length mismatch is never a
|
|
* match. Accepts typed arrays so the cached index can stay in `Float32Array`.
|
|
*/
|
|
export const faceEuclideanDistance = (a: ArrayLike<number>, b: ArrayLike<number>): number => {
|
|
if (a.length !== b.length || a.length === 0) return Number.POSITIVE_INFINITY
|
|
|
|
let sum = 0
|
|
for (let index = 0; index < a.length; index += 1) {
|
|
const delta = a[index] - b[index]
|
|
sum += delta * delta
|
|
}
|
|
return Math.sqrt(sum)
|
|
}
|
|
|
|
/**
|
|
* One indexed face of the approved archive: the photo it was found in and its
|
|
* descriptor. The row has no identity by construction — this is the scope B
|
|
* (anonymous) contract.
|
|
*/
|
|
export type FaceDescriptorEntry = {
|
|
photoId: number
|
|
vector: ArrayLike<number>
|
|
}
|
|
|
|
/**
|
|
* The distinct photo ids with at least one indexed face strictly under
|
|
* `maxDistance` from the query vector, in the order the photos appear in the
|
|
* index. `FACE_SEARCH_MAX_DISTANCE` is the only threshold: no caller ever
|
|
* receives a distance or a score.
|
|
*/
|
|
export const findFaceDescriptorPhotoIds = (
|
|
vector: ArrayLike<number>,
|
|
descriptors: readonly FaceDescriptorEntry[],
|
|
): number[] => {
|
|
const matched: number[] = []
|
|
const seen = new Set<number>()
|
|
|
|
for (const entry of descriptors) {
|
|
if (seen.has(entry.photoId)) continue
|
|
if (faceEuclideanDistance(vector, entry.vector) >= FACE_SEARCH_MAX_DISTANCE) continue
|
|
seen.add(entry.photoId)
|
|
matched.push(entry.photoId)
|
|
}
|
|
|
|
return matched
|
|
}
|
|
|
|
/**
|
|
* What one matched photo looks like to the visitor: the album's public view
|
|
* minus the third-party names (`people`/`peopleLabel`) and minus anything
|
|
* numeric — the shape is the guardrail.
|
|
*/
|
|
export type FaceSearchPhotoView = {
|
|
id: number
|
|
alt: string
|
|
title: string
|
|
takenOn: string | null
|
|
shortDateLabel: string | null
|
|
municipalityName: string | null
|
|
sceneLabel: string | null
|
|
metaLabel: string
|
|
thumbnailPath: string
|
|
mediaPath: string
|
|
downloadPath: string
|
|
downloadFilename: string
|
|
}
|
|
|
|
export const toFaceSearchPhotoView = (item: ArchivePhotoPublicItem): FaceSearchPhotoView => ({
|
|
id: item.id,
|
|
alt: item.alt,
|
|
title: item.title,
|
|
takenOn: item.takenOn,
|
|
shortDateLabel: item.shortDateLabel,
|
|
municipalityName: item.municipalityName,
|
|
sceneLabel: item.sceneLabel,
|
|
metaLabel: item.metaLabel,
|
|
thumbnailPath: item.thumbnailPath,
|
|
mediaPath: item.mediaPath,
|
|
downloadPath: item.downloadPath,
|
|
downloadFilename: item.downloadFilename,
|
|
})
|
|
|
|
/**
|
|
* Deterministic stand-in for the on-device engine (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`,
|
|
* e2e builds only): the same bytes always derive the same descriptor, so the
|
|
* spec can seed an indexed face whose vector equals the fixture's without ever
|
|
* loading a model. Never used outside the stub seam.
|
|
*/
|
|
export const faceStubDescriptorFromBytes = (bytes: Uint8Array): number[] => {
|
|
let hash = 0x811c9dc5
|
|
for (const byte of bytes) {
|
|
hash ^= byte
|
|
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
}
|
|
|
|
let state = hash || 0x9e3779b9
|
|
const vector: number[] = []
|
|
for (let index = 0; index < FACE_SEARCH_DESCRIPTOR_LENGTH; index += 1) {
|
|
state ^= state << 13
|
|
state ^= state >>> 17
|
|
state ^= state << 5
|
|
state >>>= 0
|
|
vector.push((state / 0xffffffff) * 2 - 1)
|
|
}
|
|
return vector
|
|
}
|