diff --git a/src/client.ts b/src/client.ts index 9a740d2..8d45a18 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,6 +13,8 @@ import type { VerifyResult, IdentifyInput, IdentifyResult, + CrossCollectionSearchInput, + CrossCollectionSearchResult, CompareInput, LivenessInput, LivenessResult, @@ -408,6 +410,16 @@ export class FacesResource { ); } + /** Search for matching faces across multiple or all collections. */ + async search(input: CrossCollectionSearchInput): Promise { + const form = new FormData(); + form.append("image", await toBlob(input.image), input.filename ?? "image.jpg"); + if (input.collectionIds?.length) form.append("collection_ids", input.collectionIds.join(",")); + if (input.topK !== undefined) form.append("top_k", String(input.topK)); + if (input.threshold !== undefined) form.append("threshold", String(input.threshold)); + return this.client._request("POST", "/search", { formData: form }); + } + /** * Liveness detection — check whether the face in the image is live (anti-spoofing). */ diff --git a/src/index.ts b/src/index.ts index 3f70f2f..7b0cb05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,10 @@ export type { IdentifyMatch, IdentifyInput, IdentifyResult, + CrossCollectionSearchInput, + CrossCollectionSearchMatch, + CrossCollectionSearchResult, + SkippedCollection, DetectedFace, CompareInput, LivenessInput, diff --git a/src/types.ts b/src/types.ts index 9c68971..4799d15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -128,6 +128,33 @@ export interface IdentifyResult { imageSize?: ImageSize } +export interface CrossCollectionSearchInput { + image: Blob | Buffer | ArrayBuffer + collectionIds?: string[] + topK?: number + threshold?: number + filename?: string +} + +export interface CrossCollectionSearchMatch extends IdentifyMatch { + collectionId: string +} + +export interface SkippedCollection { + id: string + name: string + reason: string +} + +export interface CrossCollectionSearchResult { + matches: CrossCollectionSearchMatch[] + queryTimeMs: number + collectionsSearched: number + skippedCollections: SkippedCollection[] + detectedFaces?: DetectedFace[] + imageSize?: ImageSize +} + export interface CompareInput { image1: Blob | Buffer | ArrayBuffer image2: Blob | Buffer | ArrayBuffer diff --git a/test/contract.test.js b/test/contract.test.js index 313be0f..b9dba67 100644 --- a/test/contract.test.js +++ b/test/contract.test.js @@ -37,6 +37,7 @@ const cases = { delete: (f) => f.delete(col, 'face_1'), verify: (f) => f.verify(col, { image: img(), faceId: 'face_1', threshold: 0.5 }), identify: (f) => f.identify(col, { image: img(), topK: 3, threshold: 0.5 }), + search: (f) => f.search({ image: img(), collectionIds: ['col_1', 'col_2'], topK: 3, threshold: 0.5 }), liveness: (f) => f.liveness(col, { image: img() }), activeLiveness: (f) => f.activeLiveness(col, Array.from({ length: 5 }, img)), createLivenessSession: (f) => f.createLivenessSession(col), diff --git a/test/faces.test.js b/test/faces.test.js index 476424f..32ab6c6 100644 --- a/test/faces.test.js +++ b/test/faces.test.js @@ -140,6 +140,17 @@ test('activeLiveness surfaces IMAGE_REQUIRED', async () => { const face = { id: 'f1', collectionId: 'col_1', externalId: 'u1', metadata: {}, createdAt: 'x' } +test('search parses skipped collections and surfaces typed profile mismatch', async () => { + stubFetch(200, { success: true, data: { matches: [], queryTimeMs: 8, collectionsSearched: 1, skippedCollections: [{ id: 'c2', name: 'Legacy', reason: 'embedding_profile_mismatch' }] } }) + const result = await client.faces.search({ image: img(), collectionIds: ['c1', 'c2'], topK: 3 }) + assert.equal(calls[0].url, 'http://api.test/api/v1/search') + assert.equal(calls[0].init.body.get('collection_ids'), 'c1,c2') + assert.equal(result.skippedCollections[0].reason, 'embedding_profile_mismatch') + + stubFetch(409, { success: false, error: { code: 'EMBEDDING_PROFILE_MISMATCH', message: 'no compatible collections' } }) + await assert.rejects(client.faces.search({ image: img() }), (err) => err instanceof LiveXFaceApiError && err.statusCode === 409 && err.code === 'EMBEDDING_PROFILE_MISMATCH') +}) + test('register sends liveness_token when given', async () => { stubFetch(201, { success: true, data: face }) await client.faces.register('col_1', { externalId: 'u1', image: img(), livenessToken: 'lvt_abc' })