openvibers/openvibe.sdk Commits
0.79.0: vision.face.familiar — a camera owner’s own face library (#108)
Changes
@@@ -2,6 +2,16 @@## Unreleased++- **`openvibe-sdk/vision`: familiar faces (`face.familiar`), F1 of the L24 plan.** The camera owner's own Vision++ library, reached with the owner's token:++ - `list`, `create({ camera_ids, region, threshold })`, `get`, `update` and `remove` the library. The region picks++ strict mode (Illinois, Texas, Washington, the EU, the UK; an unknown region too) or open mode.++ - `enroll(libraryId, image, { name, subject })` names the crop the camera saw; in strict mode it answers a++ `pending_consent` person with no template and a private `consent_request.url` until the person accepts.++ - `match(libraryId, image)` matches only that library: a top match over its threshold, or `unknown person`.++ - `people`, `patchPerson` (rename or mute), `removePerson`, `requestConsent`, `checkConsent`, `acceptConsent` and++ `denyConsent`.++ - The types in `types/vision.d.ts` say what the JSON says; no template, crop or other library is ever in an answer.- **`openvibe-sdk/live`: the rest of the Go live screen.** `live.slots.history(id)` (the slot's past streams),`live.slots.quality(id, { resolution, fps, bitrate })` and `live.slots.titleIdeas(id, { weekday, hour, title })` (thecard's ✨ Suggest: three titles for the next stream). With these, an app does everything Live's Go live page does@@@ -2285,6 +2285,26 @@ function createVisionClient(client, { baseUrl, audience = DEFAULT_AUDIENCE } = {cluster: (region) => call({ method: 'POST', path: '/api/v1/faces/library/cluster', json: { region } }),groups: () => call({ path: '/api/v1/faces/library/groups' }),correct: (action, ids) => call({ method: 'POST', path: '/api/v1/faces/library/correct', json: { action, ids } }),++ /**++ * F1 familiar faces: the camera owner's own library. A named person may stay pending_consent in a strict++ * region until Network's registry says yes (or they accept the private link); only that library is matched.++ */++ familiar: {++ list: () => call({ path: '/api/v1/faces/libraries' }),++ create: (body) => call({ method: 'POST', path: '/api/v1/faces/libraries', json: body }),++ get: (libraryId) => call({ path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}` }),++ update: (libraryId, body) => call({ method: 'PATCH', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}`, json: body }),++ remove: (libraryId) => call({ method: 'DELETE', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}` }),++ enroll: (libraryId, image, fields = {}, opts) => one(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/enroll`, image, fields, opts),++ match: (libraryId, image, opts) => one(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/match`, image, {}, opts),++ people: (libraryId) => call({ path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people` }),++ patchPerson: (libraryId, personId, body) => call({ method: 'PATCH', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}`, json: body }),++ removePerson: (libraryId, personId) => call({ method: 'DELETE', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}` }),++ requestConsent: (libraryId, personId) => call({ method: 'POST', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}/request` }),++ checkConsent: (libraryId) => call({ method: 'POST', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/consent-check` }),++ acceptConsent: (token, body) => call({ method: 'POST', path: `/api/v1/faces/consent-requests/${encodeURIComponent(token)}/accept`, json: body }),++ denyConsent: (token) => call({ method: 'POST', path: `/api/v1/faces/consent-requests/${encodeURIComponent(token)}/deny` }),++ },};return {@@@ -87,6 +87,26 @@ function createVisionClient(client, { baseUrl, audience = DEFAULT_AUDIENCE } = {cluster: (region) => call({ method: 'POST', path: '/api/v1/faces/library/cluster', json: { region } }),groups: () => call({ path: '/api/v1/faces/library/groups' }),correct: (action, ids) => call({ method: 'POST', path: '/api/v1/faces/library/correct', json: { action, ids } }),++ /**++ * F1 familiar faces: the camera owner's own library. A named person may stay pending_consent in a strict++ * region until Network's registry says yes (or they accept the private link); only that library is matched.++ */++ familiar: {++ list: () => call({ path: '/api/v1/faces/libraries' }),++ create: (body) => call({ method: 'POST', path: '/api/v1/faces/libraries', json: body }),++ get: (libraryId) => call({ path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}` }),++ update: (libraryId, body) => call({ method: 'PATCH', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}`, json: body }),++ remove: (libraryId) => call({ method: 'DELETE', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}` }),++ enroll: (libraryId, image, fields = {}, opts) => one(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/enroll`, image, fields, opts),++ match: (libraryId, image, opts) => one(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/match`, image, {}, opts),++ people: (libraryId) => call({ path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people` }),++ patchPerson: (libraryId, personId, body) => call({ method: 'PATCH', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}`, json: body }),++ removePerson: (libraryId, personId) => call({ method: 'DELETE', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}` }),++ requestConsent: (libraryId, personId) => call({ method: 'POST', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}/request` }),++ checkConsent: (libraryId) => call({ method: 'POST', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/consent-check` }),++ acceptConsent: (token, body) => call({ method: 'POST', path: `/api/v1/faces/consent-requests/${encodeURIComponent(token)}/accept`, json: body }),++ denyConsent: (token) => call({ method: 'POST', path: `/api/v1/faces/consent-requests/${encodeURIComponent(token)}/deny` }),++ },};return {@@@ -93,6 +93,37 @@ run([await srv.close();}],++ ['familiar faces: libraries, a crop enrolment and a library-scoped match use their own routes', async () => {++ const person = { id: 'flp_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4', library_id: 'flb_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4', name: 'Sam', subject: null, status: 'pending_consent', consent_basis: null, muted: false, has_template: false, created_at: '2026-10-31T10:00:00.000Z', updated_at: '2026-10-31T10:00:00.000Z', last_matched_at: null };++ const library = { id: person.library_id, camera_ids: ['cam_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4'], region: 'US-IL', region_mode: 'strict', threshold: 0.363, created_at: '2026-10-31T10:00:00.000Z', updated_at: '2026-10-31T10:00:00.000Z' };++ const srv = await stubServer((req, res) => {++ if (req.url === '/api/v1/faces/libraries' && req.method === 'GET') return send(res, 200, { count: 1, libraries: [library] });++ if (req.url === '/api/v1/faces/libraries' && req.method === 'POST') return send(res, 201, library);++ if (req.url.endsWith('/enroll')) return send(res, 200, { person, consent_request: { token: 'tok', url: 'https://openvibe.vision/people/privacy?face_request=tok', expires_at: '2026-11-14T10:00:00.000Z', sent: false, text_version: 'face-library-2026-10-28' }, enrolled: false });++ if (req.url.endsWith('/match')) return send(res, 200, { library_id: library.id, match: false, label: 'unknown person', name: null, person_id: null, muted: false, score: 0.1, threshold: 0.363 });++ if (req.url.endsWith('/consent-check')) return send(res, 200, { checked: 1, activated: [] });++ return send(res, 200, { people: [person] });++ });++ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token' }));++ const listed = await vision.face.familiar.list();++ assert.equal(listed.libraries[0].region_mode, 'strict');++ const made = await vision.face.familiar.create({ camera_ids: library.camera_ids, region: 'US-IL' });++ assert.equal(made.id, library.id);++ assert.deepEqual(JSON.parse(srv.requests[1].body), { camera_ids: library.camera_ids, region: 'US-IL' });++ const enrolled = await vision.face.familiar.enroll(library.id, Buffer.from('crop'), { name: 'Sam' });++ assert.equal(enrolled.person.status, 'pending_consent');++ const form = srv.requests[2].body.toString('latin1');++ assert.match(form, /name="name"\r\n\r\nSam/);++ assert.match(form, /name="image"; filename="image"/);++ const matched = await vision.face.familiar.match(library.id, Buffer.from('crop'));++ assert.equal(matched.label, 'unknown person');++ assert.match(srv.requests[3].body.toString('latin1'), /name="image"/);++ const checked = await vision.face.familiar.checkConsent(library.id);++ assert.equal(checked.checked, 1);++ assert.equal(srv.requests[4].url, `/api/v1/faces/libraries/${library.id}/consent-check`);++ await srv.close();++ }],++['a refusal is the service\'s problem details, not a silent empty answer', async () => {const srv = await stubServer((req, res) => problem(res, 403, 'faces.consent_required', 'Enable verify consent first.'));const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token', retries: 0 }));@@@ -100,6 +100,63 @@ export interface VisionFaceLibraryGroup {created_at: string;}++export interface VisionFamiliarLibrary {++ id: string;++ camera_ids: string[];++ region: string;++ region_mode: 'strict' | 'open';++ threshold: number;++ created_at: string;++ updated_at: string;++ people_count?: number;++}++++export interface VisionFamiliarPerson {++ id: string;++ library_id: string;++ name: string;++ subject: string | null;++ status: 'active' | 'pending_consent' | 'denied';++ consent_basis: 'network' | 'request' | null;++ muted: boolean;++ has_template: boolean;++ created_at: string;++ updated_at: string;++ last_matched_at: string | null;++}++++export interface VisionFamiliarConsentRequest {++ token: string;++ url: string;++ expires_at: string;++ sent: boolean;++ text_version: string;++}++++export interface VisionFamiliarEnrolResult {++ person: VisionFamiliarPerson;++ consent_request: VisionFamiliarConsentRequest | null;++ enrolled: boolean;++}++++export interface VisionFamiliarMatch {++ library_id: string;++ match: boolean;++ label: string;++ name: string | null;++ person_id: string | null;++ muted: boolean;++ score: number | null;++ threshold: number;++}++++export interface VisionFamiliarAcceptResult {++ accepted: true;++ library_id: string;++ person: VisionFamiliarPerson;++ text_version: string;++}++export interface VisionUiCheckFinding {kind: 'overflow' | 'cut_off' | 'contrast' | 'overlap' | 'layering';severity: 'high' | 'medium' | 'low';@@@ -189,6 +246,23 @@ export interface VisionClient {cluster(region: string): Promise<VisionFaceLibraryGroup[]>;groups(): Promise<VisionFaceLibraryGroup[]>;correct(action: 'exclude' | 'merge' | 'split', ids: string[]): Promise<{ action: 'exclude' | 'merge' | 'split'; updated: number }>;++ /** F1: a camera owner's own library. Only this library is ever matched; strict regions need consent first. */++ familiar: {++ list(): Promise<{ count: number; libraries: VisionFamiliarLibrary[] }>;++ create(body: { camera_ids: string[]; region?: string; threshold?: number }): Promise<VisionFamiliarLibrary>;++ get(libraryId: string): Promise<VisionFamiliarLibrary & { people: VisionFamiliarPerson[] }>;++ update(libraryId: string, body: { camera_ids?: string[]; region?: string; threshold?: number }): Promise<VisionFamiliarLibrary & { people: VisionFamiliarPerson[] }>;++ remove(libraryId: string): Promise<{ deleted: true; id: string; people: number; crops: number; templates: number; requests: number }>;++ enroll(libraryId: string, image: VisionImage, fields: { name: string; subject?: string }, opts?: { filename?: string; contentType?: string }): Promise<VisionFamiliarEnrolResult>;++ match(libraryId: string, image: VisionImage, opts?: { filename?: string; contentType?: string }): Promise<VisionFamiliarMatch>;++ people(libraryId: string): Promise<{ library: VisionFamiliarLibrary; people: VisionFamiliarPerson[] }>;++ patchPerson(libraryId: string, personId: string, body: { name?: string; muted?: boolean }): Promise<VisionFamiliarPerson>;++ removePerson(libraryId: string, personId: string): Promise<{ deleted: true; id: string; name: string; templates: number; crops: number; requests: number }>;++ requestConsent(libraryId: string, personId: string): Promise<VisionFamiliarConsentRequest>;++ checkConsent(libraryId: string): Promise<{ checked: number; activated: VisionFamiliarPerson[] }>;++ acceptConsent(token: string, body: { signed_name: string; text_version: string }): Promise<VisionFamiliarAcceptResult>;++ denyConsent(token: string): Promise<{ denied: true; library_id: string }>;++ };};}
@@ -2,6 +2,16 @@
@@ -2,6 +2,16 @@
## Unreleased
## Unreleased
+- **`openvibe-sdk/vision`: familiar faces (`face.familiar`), F1 of the L24 plan.** The camera owner's own Vision
+ library, reached with the owner's token:
+ - `list`, `create({ camera_ids, region, threshold })`, `get`, `update` and `remove` the library. The region picks
+ strict mode (Illinois, Texas, Washington, the EU, the UK; an unknown region too) or open mode.
+ - `enroll(libraryId, image, { name, subject })` names the crop the camera saw; in strict mode it answers a
+ `pending_consent` person with no template and a private `consent_request.url` until the person accepts.
+ - `match(libraryId, image)` matches only that library: a top match over its threshold, or `unknown person`.
+ - `people`, `patchPerson` (rename or mute), `removePerson`, `requestConsent`, `checkConsent`, `acceptConsent` and
+ `denyConsent`.
+ - The types in `types/vision.d.ts` say what the JSON says; no template, crop or other library is ever in an answer.
- **`openvibe-sdk/live`: the rest of the Go live screen.** `live.slots.history(id)` (the slot's past streams),
- **`openvibe-sdk/live`: the rest of the Go live screen.** `live.slots.history(id)` (the slot's past streams),
`live.slots.quality(id, { resolution, fps, bitrate })` and `live.slots.titleIdeas(id, { weekday, hour, title })` (the
`live.slots.quality(id, { resolution, fps, bitrate })` and `live.slots.titleIdeas(id, { weekday, hour, title })` (the
card's ✨ Suggest: three titles for the next stream). With these, an app does everything Live's Go live page does
card's ✨ Suggest: three titles for the next stream). With these, an app does everything Live's Go live page does
@@ -2285,6 +2285,26 @@ function createVisionClient(client, { baseUrl, audience = DEFAULT_AUDIENCE } = {
@@ -2285,6 +2285,26 @@ function createVisionClient(client, { baseUrl, audience = DEFAULT_AUDIENCE } = {
cluster: (region) => call({ method: 'POST', path: '/api/v1/faces/library/cluster', json: { region } }),
cluster: (region) => call({ method: 'POST', path: '/api/v1/faces/library/cluster', json: { region } }),
groups: () => call({ path: '/api/v1/faces/library/groups' }),
groups: () => call({ path: '/api/v1/faces/library/groups' }),
correct: (action, ids) => call({ method: 'POST', path: '/api/v1/faces/library/correct', json: { action, ids } }),
correct: (action, ids) => call({ method: 'POST', path: '/api/v1/faces/library/correct', json: { action, ids } }),
+ /**
+ * F1 familiar faces: the camera owner's own library. A named person may stay pending_consent in a strict
+ * region until Network's registry says yes (or they accept the private link); only that library is matched.
+ */
+ familiar: {
+ list: () => call({ path: '/api/v1/faces/libraries' }),
+ create: (body) => call({ method: 'POST', path: '/api/v1/faces/libraries', json: body }),
+ get: (libraryId) => call({ path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}` }),
+ update: (libraryId, body) => call({ method: 'PATCH', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}`, json: body }),
+ remove: (libraryId) => call({ method: 'DELETE', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}` }),
+ enroll: (libraryId, image, fields = {}, opts) => one(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/enroll`, image, fields, opts),
+ match: (libraryId, image, opts) => one(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/match`, image, {}, opts),
+ people: (libraryId) => call({ path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people` }),
+ patchPerson: (libraryId, personId, body) => call({ method: 'PATCH', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}`, json: body }),
+ removePerson: (libraryId, personId) => call({ method: 'DELETE', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}` }),
+ requestConsent: (libraryId, personId) => call({ method: 'POST', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}/request` }),
+ checkConsent: (libraryId) => call({ method: 'POST', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/consent-check` }),
+ acceptConsent: (token, body) => call({ method: 'POST', path: `/api/v1/faces/consent-requests/${encodeURIComponent(token)}/accept`, json: body }),
+ denyConsent: (token) => call({ method: 'POST', path: `/api/v1/faces/consent-requests/${encodeURIComponent(token)}/deny` }),
+ },
};
};
return {
return {
@@ -87,6 +87,26 @@ function createVisionClient(client, { baseUrl, audience = DEFAULT_AUDIENCE } = {
@@ -87,6 +87,26 @@ function createVisionClient(client, { baseUrl, audience = DEFAULT_AUDIENCE } = {
cluster: (region) => call({ method: 'POST', path: '/api/v1/faces/library/cluster', json: { region } }),
cluster: (region) => call({ method: 'POST', path: '/api/v1/faces/library/cluster', json: { region } }),
groups: () => call({ path: '/api/v1/faces/library/groups' }),
groups: () => call({ path: '/api/v1/faces/library/groups' }),
correct: (action, ids) => call({ method: 'POST', path: '/api/v1/faces/library/correct', json: { action, ids } }),
correct: (action, ids) => call({ method: 'POST', path: '/api/v1/faces/library/correct', json: { action, ids } }),
+ /**
+ * F1 familiar faces: the camera owner's own library. A named person may stay pending_consent in a strict
+ * region until Network's registry says yes (or they accept the private link); only that library is matched.
+ */
+ familiar: {
+ list: () => call({ path: '/api/v1/faces/libraries' }),
+ create: (body) => call({ method: 'POST', path: '/api/v1/faces/libraries', json: body }),
+ get: (libraryId) => call({ path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}` }),
+ update: (libraryId, body) => call({ method: 'PATCH', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}`, json: body }),
+ remove: (libraryId) => call({ method: 'DELETE', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}` }),
+ enroll: (libraryId, image, fields = {}, opts) => one(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/enroll`, image, fields, opts),
+ match: (libraryId, image, opts) => one(`/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/match`, image, {}, opts),
+ people: (libraryId) => call({ path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people` }),
+ patchPerson: (libraryId, personId, body) => call({ method: 'PATCH', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}`, json: body }),
+ removePerson: (libraryId, personId) => call({ method: 'DELETE', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}` }),
+ requestConsent: (libraryId, personId) => call({ method: 'POST', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/people/${encodeURIComponent(personId)}/request` }),
+ checkConsent: (libraryId) => call({ method: 'POST', path: `/api/v1/faces/libraries/${encodeURIComponent(libraryId)}/consent-check` }),
+ acceptConsent: (token, body) => call({ method: 'POST', path: `/api/v1/faces/consent-requests/${encodeURIComponent(token)}/accept`, json: body }),
+ denyConsent: (token) => call({ method: 'POST', path: `/api/v1/faces/consent-requests/${encodeURIComponent(token)}/deny` }),
+ },
};
};
return {
return {
@@ -93,6 +93,37 @@ run([
@@ -93,6 +93,37 @@ run([
await srv.close();
await srv.close();
}],
}],
+ ['familiar faces: libraries, a crop enrolment and a library-scoped match use their own routes', async () => {
+ const person = { id: 'flp_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4', library_id: 'flb_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4', name: 'Sam', subject: null, status: 'pending_consent', consent_basis: null, muted: false, has_template: false, created_at: '2026-10-31T10:00:00.000Z', updated_at: '2026-10-31T10:00:00.000Z', last_matched_at: null };
+ const library = { id: person.library_id, camera_ids: ['cam_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4'], region: 'US-IL', region_mode: 'strict', threshold: 0.363, created_at: '2026-10-31T10:00:00.000Z', updated_at: '2026-10-31T10:00:00.000Z' };
+ const srv = await stubServer((req, res) => {
+ if (req.url === '/api/v1/faces/libraries' && req.method === 'GET') return send(res, 200, { count: 1, libraries: [library] });
+ if (req.url === '/api/v1/faces/libraries' && req.method === 'POST') return send(res, 201, library);
+ if (req.url.endsWith('/enroll')) return send(res, 200, { person, consent_request: { token: 'tok', url: 'https://openvibe.vision/people/privacy?face_request=tok', expires_at: '2026-11-14T10:00:00.000Z', sent: false, text_version: 'face-library-2026-10-28' }, enrolled: false });
+ if (req.url.endsWith('/match')) return send(res, 200, { library_id: library.id, match: false, label: 'unknown person', name: null, person_id: null, muted: false, score: 0.1, threshold: 0.363 });
+ if (req.url.endsWith('/consent-check')) return send(res, 200, { checked: 1, activated: [] });
+ return send(res, 200, { people: [person] });
+ });
+ const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token' }));
+ const listed = await vision.face.familiar.list();
+ assert.equal(listed.libraries[0].region_mode, 'strict');
+ const made = await vision.face.familiar.create({ camera_ids: library.camera_ids, region: 'US-IL' });
+ assert.equal(made.id, library.id);
+ assert.deepEqual(JSON.parse(srv.requests[1].body), { camera_ids: library.camera_ids, region: 'US-IL' });
+ const enrolled = await vision.face.familiar.enroll(library.id, Buffer.from('crop'), { name: 'Sam' });
+ assert.equal(enrolled.person.status, 'pending_consent');
+ const form = srv.requests[2].body.toString('latin1');
+ assert.match(form, /name="name"\r\n\r\nSam/);
+ assert.match(form, /name="image"; filename="image"/);
+ const matched = await vision.face.familiar.match(library.id, Buffer.from('crop'));
+ assert.equal(matched.label, 'unknown person');
+ assert.match(srv.requests[3].body.toString('latin1'), /name="image"/);
+ const checked = await vision.face.familiar.checkConsent(library.id);
+ assert.equal(checked.checked, 1);
+ assert.equal(srv.requests[4].url, `/api/v1/faces/libraries/${library.id}/consent-check`);
+ await srv.close();
+ }],
+
['a refusal is the service\'s problem details, not a silent empty answer', async () => {
['a refusal is the service\'s problem details, not a silent empty answer', async () => {
const srv = await stubServer((req, res) => problem(res, 403, 'faces.consent_required', 'Enable verify consent first.'));
const srv = await stubServer((req, res) => problem(res, 403, 'faces.consent_required', 'Enable verify consent first.'));
const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token', retries: 0 }));
const vision = createVisionClient(createClient({ baseUrls: { vision: srv.url }, token: 'user-token', retries: 0 }));
@@ -100,6 +100,63 @@ export interface VisionFaceLibraryGroup {
@@ -100,6 +100,63 @@ export interface VisionFaceLibraryGroup {
created_at: string;
created_at: string;
}
}
+export interface VisionFamiliarLibrary {
+ id: string;
+ camera_ids: string[];
+ region: string;
+ region_mode: 'strict' | 'open';
+ threshold: number;
+ created_at: string;
+ updated_at: string;
+ people_count?: number;
+}
+
+export interface VisionFamiliarPerson {
+ id: string;
+ library_id: string;
+ name: string;
+ subject: string | null;
+ status: 'active' | 'pending_consent' | 'denied';
+ consent_basis: 'network' | 'request' | null;
+ muted: boolean;
+ has_template: boolean;
+ created_at: string;
+ updated_at: string;
+ last_matched_at: string | null;
+}
+
+export interface VisionFamiliarConsentRequest {
+ token: string;
+ url: string;
+ expires_at: string;
+ sent: boolean;
+ text_version: string;
+}
+
+export interface VisionFamiliarEnrolResult {
+ person: VisionFamiliarPerson;
+ consent_request: VisionFamiliarConsentRequest | null;
+ enrolled: boolean;
+}
+
+export interface VisionFamiliarMatch {
+ library_id: string;
+ match: boolean;
+ label: string;
+ name: string | null;
+ person_id: string | null;
+ muted: boolean;
+ score: number | null;
+ threshold: number;
+}
+
+export interface VisionFamiliarAcceptResult {
+ accepted: true;
+ library_id: string;
+ person: VisionFamiliarPerson;
+ text_version: string;
+}
+
export interface VisionUiCheckFinding {
export interface VisionUiCheckFinding {
kind: 'overflow' | 'cut_off' | 'contrast' | 'overlap' | 'layering';
kind: 'overflow' | 'cut_off' | 'contrast' | 'overlap' | 'layering';
severity: 'high' | 'medium' | 'low';
severity: 'high' | 'medium' | 'low';
@@ -189,6 +246,23 @@ export interface VisionClient {
@@ -189,6 +246,23 @@ export interface VisionClient {
cluster(region: string): Promise<VisionFaceLibraryGroup[]>;
cluster(region: string): Promise<VisionFaceLibraryGroup[]>;
groups(): Promise<VisionFaceLibraryGroup[]>;
groups(): Promise<VisionFaceLibraryGroup[]>;
correct(action: 'exclude' | 'merge' | 'split', ids: string[]): Promise<{ action: 'exclude' | 'merge' | 'split'; updated: number }>;
correct(action: 'exclude' | 'merge' | 'split', ids: string[]): Promise<{ action: 'exclude' | 'merge' | 'split'; updated: number }>;
+ /** F1: a camera owner's own library. Only this library is ever matched; strict regions need consent first. */
+ familiar: {
+ list(): Promise<{ count: number; libraries: VisionFamiliarLibrary[] }>;
+ create(body: { camera_ids: string[]; region?: string; threshold?: number }): Promise<VisionFamiliarLibrary>;
+ get(libraryId: string): Promise<VisionFamiliarLibrary & { people: VisionFamiliarPerson[] }>;
+ update(libraryId: string, body: { camera_ids?: string[]; region?: string; threshold?: number }): Promise<VisionFamiliarLibrary & { people: VisionFamiliarPerson[] }>;
+ remove(libraryId: string): Promise<{ deleted: true; id: string; people: number; crops: number; templates: number; requests: number }>;
+ enroll(libraryId: string, image: VisionImage, fields: { name: string; subject?: string }, opts?: { filename?: string; contentType?: string }): Promise<VisionFamiliarEnrolResult>;
+ match(libraryId: string, image: VisionImage, opts?: { filename?: string; contentType?: string }): Promise<VisionFamiliarMatch>;
+ people(libraryId: string): Promise<{ library: VisionFamiliarLibrary; people: VisionFamiliarPerson[] }>;
+ patchPerson(libraryId: string, personId: string, body: { name?: string; muted?: boolean }): Promise<VisionFamiliarPerson>;
+ removePerson(libraryId: string, personId: string): Promise<{ deleted: true; id: string; name: string; templates: number; crops: number; requests: number }>;
+ requestConsent(libraryId: string, personId: string): Promise<VisionFamiliarConsentRequest>;
+ checkConsent(libraryId: string): Promise<{ checked: number; activated: VisionFamiliarPerson[] }>;
+ acceptConsent(token: string, body: { signed_name: string; text_version: string }): Promise<VisionFamiliarAcceptResult>;
+ denyConsent(token: string): Promise<{ denied: true; library_id: string }>;
+ };
};
};
}
}