openvibers/openvibe.sdk Commits

0.79.0: vision.face.familiar — a camera owner’s own face library (#108)

OP OpenVibers committed 11 days ago · 3351662a01 · 5 files, +155 −0

* 0.79.0: vision.face.familiar — a camera owner's own face library

The SDK carries F1's familiar-face calls: list/create/get/update/remove the owner's library, enrol a crop, match only that library, list/rename/mute/delete a named person, and send or accept a consent request. Types say what the JSON says.

* Merge remote-tracking branch 'origin/main' into sdk-refresh-108

# Conflicts:
#	CHANGELOG.md
#	README.md
#	STATUS.json
#	browser/openvibe-sdk.mjs
#	package-lock.json
#	package.json
#	src/core/index.js
#	test/pack.test.js

* Refresh onto main: the browser bundle rebuilt, the entry under Unreleased

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 68d6ee4..ba7d353 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 })` (the
card's ✨ Suggest: three titles for the next stream). With these, an app does everything Live's Go live page does
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 8761989..b910bf9 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -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 {
ddiff --git a/src/vision.js b/src/vision.js
iindex 011b3ee..93318d3 100644
---- a/src/vision.js
++++ b/src/vision.js
@@@ -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 {
ddiff --git a/test/vision.test.js b/test/vision.test.js
iindex 2af9727..162a1dc 100644
---- a/test/vision.test.js
++++ b/test/vision.test.js
@@@ -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 }));
ddiff --git a/types/vision.d.ts b/types/vision.d.ts
iindex c0c414f..ccbbe47 100644
---- a/types/vision.d.ts
++++ b/types/vision.d.ts
@@@ -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 }>;
++ };
};
}