teqo-backup/scripts/lib/faceEngine.mjs
Francisco Solla 9e9864f420 feat(C234): busca por selfie no álbum público — índice A/C consentido, selfie on-device
- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e
  sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default
  false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não
  seedados — a superfície nasce fechada até o aval jurídico/DPIA).
- Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são
  transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos
  matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt).
- Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin
  (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node.
  CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232.
- Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233;
  rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon.
- UI portada classe-a-classe do design estendido (paridade certificada, tier primário);
  e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine
  real; runbook §C234, contrato em AGENTS-public.md e changelog.
- Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
2026-09-27 20:04:41 -03:00

84 lines
3.1 KiB
JavaScript

/**
* C234 — the Node face engine of the maintenance CLIs: the SAME face-api nets
* the visitor's browser loads (`tinyFaceDetector` + `faceLandmark68Net` +
* `faceRecognitionNet`), with the same options, so a descriptor computed by the
* enrollment/index CLI is comparable with the one computed on the device. The
* wasm backend is the package's own Node pairing (`face-api.node-wasm.js` +
* `@tensorflow/tfjs-backend-wasm`) — no native `tfjs-node` build, no headless
* browser, no network.
*
* The engine only turns prepared pixels into descriptors; the caller owns the
* image preparation (`prepareFaceImage`) and the distance math
* (`src/lib/faceSearch.ts`). It is a manual smoke dependency, never in the
* test suite (the suites inject a stub analyzer).
*/
import { createRequire } from 'node:module'
import path from 'node:path'
const require = createRequire(import.meta.url)
/**
* Loads the nets once per process. Throws a named error when the wasm assets or
* the models are missing — a broken install must fail before any DB write.
*
* @param {string} repoRoot
*/
export const createNodeFaceEngine = async (repoRoot) => {
const { FACE_DETECTOR_INPUT_SIZE, FACE_DETECTOR_SCORE_THRESHOLD } =
await import('../../src/lib/faceSearch.ts')
let faceapi
try {
// The tfjs runtime is required explicitly first: it is the exact instance
// the node-wasm build and the wasm backend register against (same module
// cache), and it keeps the dependency honest for the static checks.
require('@tensorflow/tfjs')
faceapi = require('@vladmandic/face-api/dist/face-api.node-wasm.js')
const { setWasmPaths } = require('@tensorflow/tfjs-backend-wasm')
setWasmPaths(path.join(repoRoot, 'node_modules/@tensorflow/tfjs-backend-wasm/dist/'))
} catch (error) {
throw new Error(
`engine indisponível (instale @vladmandic/face-api e @tensorflow/tfjs-backend-wasm): ${
error instanceof Error ? error.message : error
}`,
)
}
const tf = faceapi.tf
await tf.setBackend('wasm')
await tf.ready()
const modelDir = path.join(repoRoot, 'node_modules/@vladmandic/face-api/model')
await faceapi.nets.tinyFaceDetector.loadFromDisk(modelDir)
await faceapi.nets.faceLandmark68Net.loadFromDisk(modelDir)
await faceapi.nets.faceRecognitionNet.loadFromDisk(modelDir)
const options = new faceapi.TinyFaceDetectorOptions({
inputSize: FACE_DETECTOR_INPUT_SIZE,
scoreThreshold: FACE_DETECTOR_SCORE_THRESHOLD,
})
return {
/**
* Descriptors of every detected face, in detection order.
* @param {{ pixels: Uint8Array, width: number, height: number }} image
* @returns {Promise<number[][]>}
*/
detectPixels: async ({ pixels, width, height }) => {
const tensor = tf.tensor3d(
new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength),
[height, width, 3],
'int32',
)
try {
const results = await faceapi
.detectAllFaces(tensor, options)
.withFaceLandmarks()
.withFaceDescriptors()
return results.map((result) => Array.from(result.descriptor))
} finally {
tensor.dispose()
}
},
}
}