@verifiedonchain-protocol/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -0
- package/dist/index.d.ts +563 -0
- package/dist/index.js +3003 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3003 @@
|
|
|
1
|
+
// src/core/biometrics/simHash.ts
|
|
2
|
+
var SEED_PREFIX = "HumanityGen_SimHash_Hyperplane_";
|
|
3
|
+
function generateHyperplanes(dimensions, count) {
|
|
4
|
+
const hyperplanes = [];
|
|
5
|
+
for (let i = 0; i < count; i++) {
|
|
6
|
+
const seed = `${SEED_PREFIX}${i}`;
|
|
7
|
+
const vector = [];
|
|
8
|
+
let currentSeed = simpleHash(seed);
|
|
9
|
+
const nextRandom = () => {
|
|
10
|
+
currentSeed = Math.imul(currentSeed, 1664525) + 1013904223 >>> 0;
|
|
11
|
+
return currentSeed / 4294967296;
|
|
12
|
+
};
|
|
13
|
+
for (let j = 0; j < dimensions; j += 2) {
|
|
14
|
+
const u1 = Math.max(nextRandom(), 1e-10);
|
|
15
|
+
const u2 = nextRandom();
|
|
16
|
+
const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
17
|
+
const z1 = Math.sqrt(-2 * Math.log(u1)) * Math.sin(2 * Math.PI * u2);
|
|
18
|
+
vector.push(z0);
|
|
19
|
+
if (j + 1 < dimensions) {
|
|
20
|
+
vector.push(z1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
hyperplanes.push(vector);
|
|
24
|
+
}
|
|
25
|
+
return hyperplanes;
|
|
26
|
+
}
|
|
27
|
+
function simpleHash(str) {
|
|
28
|
+
let hash = 2166136261;
|
|
29
|
+
for (let i = 0; i < str.length; i++) {
|
|
30
|
+
hash ^= str.charCodeAt(i);
|
|
31
|
+
hash = Math.imul(hash, 16777619);
|
|
32
|
+
}
|
|
33
|
+
return hash >>> 0;
|
|
34
|
+
}
|
|
35
|
+
function computeSimHash(embedding, hyperplanes) {
|
|
36
|
+
if (embedding.length === 0) return "";
|
|
37
|
+
const dims = Math.min(embedding.length, hyperplanes[0].length);
|
|
38
|
+
let signatureBytes = new Uint8Array(hyperplanes.length / 8);
|
|
39
|
+
for (let i = 0; i < hyperplanes.length; i++) {
|
|
40
|
+
let dotProduct = 0;
|
|
41
|
+
for (let j = 0; j < dims; j++) {
|
|
42
|
+
dotProduct += embedding[j] * hyperplanes[i][j];
|
|
43
|
+
}
|
|
44
|
+
if (dotProduct > 0) {
|
|
45
|
+
const byteIndex = Math.floor(i / 8);
|
|
46
|
+
const bitIndex = 7 - i % 8;
|
|
47
|
+
signatureBytes[byteIndex] |= 1 << bitIndex;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return uint8ArrayToHex(signatureBytes);
|
|
51
|
+
}
|
|
52
|
+
function uint8ArrayToHex(bytes) {
|
|
53
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
54
|
+
}
|
|
55
|
+
function similarityFromDistance(distance, totalBits = 256) {
|
|
56
|
+
return Math.max(0, 1 - distance / totalBits);
|
|
57
|
+
}
|
|
58
|
+
function hammingDistance(hex1, hex2) {
|
|
59
|
+
if (hex1.length !== hex2.length) {
|
|
60
|
+
throw new Error("Hex strings must be equal length");
|
|
61
|
+
}
|
|
62
|
+
let distance = 0;
|
|
63
|
+
for (let i = 0; i < hex1.length; i += 2) {
|
|
64
|
+
const byte1 = parseInt(hex1.substring(i, i + 2), 16);
|
|
65
|
+
const byte2 = parseInt(hex2.substring(i, i + 2), 16);
|
|
66
|
+
let xor = byte1 ^ byte2;
|
|
67
|
+
while (xor > 0) {
|
|
68
|
+
if ((xor & 1) === 1) distance++;
|
|
69
|
+
xor >>= 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return distance;
|
|
73
|
+
}
|
|
74
|
+
var cachedHyperplanes = null;
|
|
75
|
+
function getHyperplanes(dimensions = 1024, count = 256) {
|
|
76
|
+
if (!cachedHyperplanes || cachedHyperplanes[0].length !== dimensions || cachedHyperplanes.length !== count) {
|
|
77
|
+
console.time("Generating Hyperplanes");
|
|
78
|
+
cachedHyperplanes = generateHyperplanes(dimensions, count);
|
|
79
|
+
console.timeEnd("Generating Hyperplanes");
|
|
80
|
+
}
|
|
81
|
+
return cachedHyperplanes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/core/biometrics/biometricUtils.ts
|
|
85
|
+
var EXPECTED_MESH_POINTS = 468;
|
|
86
|
+
var EXPECTED_LANDMARKS = 68;
|
|
87
|
+
var MIN_EMBEDDING_LENGTH = 128;
|
|
88
|
+
var MIN_FACE_SIZE = 100;
|
|
89
|
+
var MAX_ROTATION_YAW = 30;
|
|
90
|
+
var MAX_ROTATION_PITCH = 20;
|
|
91
|
+
var MAX_ROTATION_ROLL = 15;
|
|
92
|
+
var MIN_QUALITY_SCORE = 75;
|
|
93
|
+
function averageEmbeddings(embeddings) {
|
|
94
|
+
if (!embeddings || embeddings.length === 0) return [];
|
|
95
|
+
if (embeddings.length === 1) return embeddings[0];
|
|
96
|
+
const dim = embeddings[0].length;
|
|
97
|
+
const count = embeddings.length;
|
|
98
|
+
const medianEmbedding = new Array(dim).fill(0);
|
|
99
|
+
for (let d = 0; d < dim; d++) {
|
|
100
|
+
const valuesForDim = embeddings.map((emb) => emb[Math.min(d, emb.length - 1)] || 0);
|
|
101
|
+
valuesForDim.sort((a, b) => a - b);
|
|
102
|
+
medianEmbedding[d] = valuesForDim[Math.floor(count / 2)];
|
|
103
|
+
}
|
|
104
|
+
const distances = embeddings.map((emb, index) => {
|
|
105
|
+
let sumSq = 0;
|
|
106
|
+
for (let d = 0; d < dim; d++) {
|
|
107
|
+
const diff = (emb[d] || 0) - medianEmbedding[d];
|
|
108
|
+
sumSq += diff * diff;
|
|
109
|
+
}
|
|
110
|
+
return { index, distance: Math.sqrt(sumSq) };
|
|
111
|
+
});
|
|
112
|
+
distances.sort((a, b) => a.distance - b.distance);
|
|
113
|
+
const keepCount = Math.max(1, Math.floor(count * 0.7));
|
|
114
|
+
const bestIndices = new Set(distances.slice(0, keepCount).map((d) => d.index));
|
|
115
|
+
const finalAvg = new Array(dim).fill(0);
|
|
116
|
+
for (let i = 0; i < count; i++) {
|
|
117
|
+
if (bestIndices.has(i)) {
|
|
118
|
+
for (let d = 0; d < dim; d++) {
|
|
119
|
+
finalAvg[d] += embeddings[i][d] || 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (let d = 0; d < dim; d++) {
|
|
124
|
+
finalAvg[d] /= keepCount;
|
|
125
|
+
}
|
|
126
|
+
console.log(`Averaged ${keepCount} clean embeddings (discarded ${count - keepCount} outliers)`);
|
|
127
|
+
return finalAvg;
|
|
128
|
+
}
|
|
129
|
+
function safeNumber(value, fallback = 0) {
|
|
130
|
+
return typeof value === "number" && !isNaN(value) ? value : fallback;
|
|
131
|
+
}
|
|
132
|
+
function normalizePoint(point) {
|
|
133
|
+
if (!point) return [0, 0, 0];
|
|
134
|
+
if (Array.isArray(point)) {
|
|
135
|
+
return [
|
|
136
|
+
safeNumber(point[0]),
|
|
137
|
+
safeNumber(point[1]),
|
|
138
|
+
safeNumber(point[2])
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
if (typeof point === "object") {
|
|
142
|
+
return [
|
|
143
|
+
safeNumber(point.x),
|
|
144
|
+
safeNumber(point.y),
|
|
145
|
+
safeNumber(point.z)
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
return [0, 0, 0];
|
|
149
|
+
}
|
|
150
|
+
function extractMeshData(face) {
|
|
151
|
+
if (Array.isArray(face.meshRaw) && face.meshRaw.length > 0) {
|
|
152
|
+
return face.meshRaw.map(normalizePoint);
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(face.mesh) && face.mesh.length > 0) {
|
|
155
|
+
return face.mesh.map(normalizePoint);
|
|
156
|
+
}
|
|
157
|
+
console.warn("No mesh data available, using empty mesh");
|
|
158
|
+
return Array(EXPECTED_MESH_POINTS).fill([0, 0, 0]);
|
|
159
|
+
}
|
|
160
|
+
function extractLandmarksFromMesh(mesh) {
|
|
161
|
+
if (mesh.length === 0) {
|
|
162
|
+
console.warn("No mesh data for landmarks, using empty landmarks");
|
|
163
|
+
return Array(EXPECTED_LANDMARKS).fill([0, 0]);
|
|
164
|
+
}
|
|
165
|
+
return mesh.slice(0, EXPECTED_LANDMARKS).map((point) => [point[0], point[1]]);
|
|
166
|
+
}
|
|
167
|
+
function extractBoundingBox(face, mesh) {
|
|
168
|
+
const box = face.box;
|
|
169
|
+
if (!box) {
|
|
170
|
+
return calculateBoxFromMesh(mesh);
|
|
171
|
+
}
|
|
172
|
+
if (box.x !== void 0 && box.width !== void 0) {
|
|
173
|
+
return {
|
|
174
|
+
x: safeNumber(box.x),
|
|
175
|
+
y: safeNumber(box.y),
|
|
176
|
+
width: safeNumber(box.width),
|
|
177
|
+
height: safeNumber(box.height)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (box.left !== void 0) {
|
|
181
|
+
return {
|
|
182
|
+
x: safeNumber(box.left),
|
|
183
|
+
y: safeNumber(box.top),
|
|
184
|
+
width: safeNumber(box.right - box.left),
|
|
185
|
+
height: safeNumber(box.bottom - box.top)
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(box) && box.length >= 4) {
|
|
189
|
+
return {
|
|
190
|
+
x: safeNumber(box[0]),
|
|
191
|
+
y: safeNumber(box[1]),
|
|
192
|
+
width: safeNumber(box[2]),
|
|
193
|
+
height: safeNumber(box[3])
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return calculateBoxFromMesh(mesh);
|
|
197
|
+
}
|
|
198
|
+
function calculateBoxFromMesh(mesh) {
|
|
199
|
+
if (mesh.length === 0) return null;
|
|
200
|
+
const xs = mesh.map((p) => p[0]).filter((x) => typeof x === "number" && !isNaN(x));
|
|
201
|
+
const ys = mesh.map((p) => p[1]).filter((y) => typeof y === "number" && !isNaN(y));
|
|
202
|
+
if (xs.length === 0 || ys.length === 0) return null;
|
|
203
|
+
const minX = Math.min(...xs);
|
|
204
|
+
const maxX = Math.max(...xs);
|
|
205
|
+
const minY = Math.min(...ys);
|
|
206
|
+
const maxY = Math.max(...ys);
|
|
207
|
+
return {
|
|
208
|
+
x: minX,
|
|
209
|
+
y: minY,
|
|
210
|
+
width: maxX - minX,
|
|
211
|
+
height: maxY - minY
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function extractRawFacialData(face) {
|
|
215
|
+
if (!face) {
|
|
216
|
+
throw new Error("No face data provided");
|
|
217
|
+
}
|
|
218
|
+
const rawEmbedding = face.embedding || [];
|
|
219
|
+
if (!rawEmbedding || rawEmbedding.length === 0) {
|
|
220
|
+
console.error("\u274C CRITICAL: Face embedding is empty!");
|
|
221
|
+
console.error("This means face.description module is not enabled in Human.js config");
|
|
222
|
+
console.error("Enable it with: face: { description: { enabled: true } }");
|
|
223
|
+
throw new Error(
|
|
224
|
+
"Face embedding not available. Ensure face.description is enabled in Human.js configuration."
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const faceEmbedding = normalizeEmbedding(Array.from(rawEmbedding));
|
|
228
|
+
const faceMesh = extractMeshData(face);
|
|
229
|
+
const faceLandmarks = extractLandmarksFromMesh(faceMesh);
|
|
230
|
+
const faceRotation = face.rotation ? {
|
|
231
|
+
angle: {
|
|
232
|
+
yaw: safeNumber(face.rotation.angle?.yaw),
|
|
233
|
+
pitch: safeNumber(face.rotation.angle?.pitch),
|
|
234
|
+
roll: safeNumber(face.rotation.angle?.roll)
|
|
235
|
+
},
|
|
236
|
+
matrix: Array.isArray(face.rotation.matrix) ? face.rotation.matrix : []
|
|
237
|
+
} : null;
|
|
238
|
+
const faceBox = extractBoundingBox(face, faceMesh);
|
|
239
|
+
const emotion = Array.isArray(face.emotion) ? face.emotion.map((em) => ({
|
|
240
|
+
emotion: em.emotion || "unknown",
|
|
241
|
+
score: safeNumber(em.score)
|
|
242
|
+
})) : void 0;
|
|
243
|
+
const result = {
|
|
244
|
+
faceEmbedding,
|
|
245
|
+
faceMesh,
|
|
246
|
+
faceLandmarks,
|
|
247
|
+
faceRotation,
|
|
248
|
+
faceBox,
|
|
249
|
+
emotion,
|
|
250
|
+
timestamp: Date.now()
|
|
251
|
+
};
|
|
252
|
+
console.log("\u2705 Facial data extracted:", {
|
|
253
|
+
embeddingLength: faceEmbedding.length,
|
|
254
|
+
meshPoints: faceMesh.length,
|
|
255
|
+
landmarksPoints: faceLandmarks.length,
|
|
256
|
+
hasRotation: !!faceRotation,
|
|
257
|
+
hasBox: !!faceBox,
|
|
258
|
+
emotions: emotion?.length || 0
|
|
259
|
+
});
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
function normalizeEmbedding(embedding) {
|
|
263
|
+
if (!embedding?.length) return [];
|
|
264
|
+
const norm = Math.sqrt(embedding.reduce((sum, val) => sum + val * val, 0));
|
|
265
|
+
if (norm === 0 || !isFinite(norm)) {
|
|
266
|
+
console.warn("Invalid norm detected, returning original embedding");
|
|
267
|
+
return embedding;
|
|
268
|
+
}
|
|
269
|
+
return embedding.map((val) => val / norm);
|
|
270
|
+
}
|
|
271
|
+
function createLivenessData(blinkCount, blinkTimestamps, emotionTransitions, headMovement, verificationDuration, stepCompletionTimes) {
|
|
272
|
+
return {
|
|
273
|
+
blinkCount,
|
|
274
|
+
blinkTimestamps: [...blinkTimestamps],
|
|
275
|
+
// Create copy
|
|
276
|
+
emotionTransitions: [...emotionTransitions],
|
|
277
|
+
// Create copy
|
|
278
|
+
headMovement: { ...headMovement },
|
|
279
|
+
// Create copy
|
|
280
|
+
verificationDuration,
|
|
281
|
+
stepCompletionTimes: { ...stepCompletionTimes }
|
|
282
|
+
// Create copy
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
var EmbeddingQuantizer = class {
|
|
286
|
+
constructor(calibrationParams) {
|
|
287
|
+
this.EPSILON = 1e-8;
|
|
288
|
+
this.DEFAULT_MIN = calibrationParams?.min ?? -2;
|
|
289
|
+
this.DEFAULT_MAX = calibrationParams?.max ?? 2;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Quantize embedding to u8 format for Aleo circuit
|
|
293
|
+
*/
|
|
294
|
+
quantizeEmbedding(embedding, targetRows = 4, targetCols = 16) {
|
|
295
|
+
const normalized = this.normalizeToRange(embedding);
|
|
296
|
+
const reshaped = this.reshapeEmbedding(normalized, targetRows, targetCols);
|
|
297
|
+
return reshaped.map(
|
|
298
|
+
(row) => row.map((value) => Math.min(255, Math.max(0, Math.round(value * 255))))
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Normalize to [0, 1] range with improved outlier handling
|
|
303
|
+
*/
|
|
304
|
+
normalizeToRange(embedding) {
|
|
305
|
+
const embArray = Array.from(embedding);
|
|
306
|
+
if (embArray.length === 0) {
|
|
307
|
+
throw new Error("Empty embedding provided");
|
|
308
|
+
}
|
|
309
|
+
const actualMin = Math.min(...embArray);
|
|
310
|
+
const actualMax = Math.max(...embArray);
|
|
311
|
+
const minVal = Math.min(actualMin, this.DEFAULT_MIN);
|
|
312
|
+
const maxVal = Math.max(actualMax, this.DEFAULT_MAX);
|
|
313
|
+
const range = maxVal - minVal + this.EPSILON;
|
|
314
|
+
return embArray.map((value) => {
|
|
315
|
+
const normalized = (value - minVal) / range;
|
|
316
|
+
return Math.max(0, Math.min(1, normalized));
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Strategic sampling with information-preserving bias
|
|
321
|
+
*/
|
|
322
|
+
reshapeEmbedding(embedding, targetRows, targetCols) {
|
|
323
|
+
const totalTarget = targetRows * targetCols;
|
|
324
|
+
const sourceLength = embedding.length;
|
|
325
|
+
if (sourceLength <= totalTarget) {
|
|
326
|
+
return this.padAndReshape(embedding, targetRows, targetCols);
|
|
327
|
+
}
|
|
328
|
+
return this.sampleAndReshape(embedding, targetRows, targetCols);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Improved sampling with better distribution
|
|
332
|
+
*/
|
|
333
|
+
sampleAndReshape(embedding, targetRows, targetCols) {
|
|
334
|
+
const totalTarget = targetRows * targetCols;
|
|
335
|
+
const sourceLength = embedding.length;
|
|
336
|
+
const sampledValues = [];
|
|
337
|
+
for (let i = 0; i < totalTarget; i++) {
|
|
338
|
+
const ratio = i / totalTarget;
|
|
339
|
+
let position;
|
|
340
|
+
if (ratio < 0.7) {
|
|
341
|
+
position = Math.floor(ratio / 0.7 * sourceLength * 0.6);
|
|
342
|
+
} else {
|
|
343
|
+
const remaining = ratio - 0.7;
|
|
344
|
+
position = Math.floor(sourceLength * 0.6 + remaining / 0.3 * sourceLength * 0.4);
|
|
345
|
+
}
|
|
346
|
+
sampledValues.push(embedding[Math.min(position, sourceLength - 1)]);
|
|
347
|
+
}
|
|
348
|
+
const result = [];
|
|
349
|
+
for (let row = 0; row < targetRows; row++) {
|
|
350
|
+
result.push(sampledValues.slice(row * targetCols, (row + 1) * targetCols));
|
|
351
|
+
}
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Pad with mean value to maintain distribution
|
|
356
|
+
*/
|
|
357
|
+
padAndReshape(embedding, targetRows, targetCols) {
|
|
358
|
+
const totalTarget = targetRows * targetCols;
|
|
359
|
+
const mean = embedding.reduce((sum, val) => sum + val, 0) / embedding.length;
|
|
360
|
+
const padded = [...embedding, ...Array(totalTarget - embedding.length).fill(mean)];
|
|
361
|
+
const result = [];
|
|
362
|
+
for (let row = 0; row < targetRows; row++) {
|
|
363
|
+
result.push(padded.slice(row * targetCols, (row + 1) * targetCols));
|
|
364
|
+
}
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Process embedding for all 4 Aleo circuit chunks
|
|
369
|
+
*/
|
|
370
|
+
processForAleo(embedding) {
|
|
371
|
+
const embArray = Array.from(embedding);
|
|
372
|
+
return {
|
|
373
|
+
embedding_chunk1: this.quantizeEmbedding(embArray, 4, 16),
|
|
374
|
+
embedding_chunk2: this.createChunkWithOffset(embArray, 0.25),
|
|
375
|
+
embedding_chunk3: this.createChunkWithOffset(embArray, 0.5),
|
|
376
|
+
embedding_chunk4: this.createChunkWithOffset(embArray, 0.75)
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Create chunk with circular offset
|
|
381
|
+
*/
|
|
382
|
+
createChunkWithOffset(embedding, offsetRatio) {
|
|
383
|
+
const offsetIndex = Math.floor(embedding.length * offsetRatio);
|
|
384
|
+
const shifted = [
|
|
385
|
+
...embedding.slice(offsetIndex),
|
|
386
|
+
...embedding.slice(0, offsetIndex)
|
|
387
|
+
];
|
|
388
|
+
return this.quantizeEmbedding(shifted, 4, 16);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Validate quantized embedding meets Aleo requirements
|
|
392
|
+
*/
|
|
393
|
+
validateForAleo(chunks) {
|
|
394
|
+
const chunkArray = [
|
|
395
|
+
chunks.embedding_chunk1,
|
|
396
|
+
chunks.embedding_chunk2,
|
|
397
|
+
chunks.embedding_chunk3,
|
|
398
|
+
chunks.embedding_chunk4
|
|
399
|
+
];
|
|
400
|
+
chunkArray.forEach((chunk, chunkIdx) => {
|
|
401
|
+
if (chunk.length !== 4) {
|
|
402
|
+
throw new Error(`Chunk ${chunkIdx + 1}: Expected 4 rows, got ${chunk.length}`);
|
|
403
|
+
}
|
|
404
|
+
chunk.forEach((row, rowIdx) => {
|
|
405
|
+
if (row.length !== 16) {
|
|
406
|
+
throw new Error(`Chunk ${chunkIdx + 1}, Row ${rowIdx}: Expected 16 cols, got ${row.length}`);
|
|
407
|
+
}
|
|
408
|
+
row.forEach((value, colIdx) => {
|
|
409
|
+
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`Chunk ${chunkIdx + 1}[${rowIdx}][${colIdx}]: Invalid u8 value ${value}`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Calibrate parameters from sample embeddings
|
|
420
|
+
*/
|
|
421
|
+
calibrateFromSamples(samples) {
|
|
422
|
+
if (samples.length === 0) {
|
|
423
|
+
throw new Error("No samples provided for calibration");
|
|
424
|
+
}
|
|
425
|
+
let globalMin = Infinity;
|
|
426
|
+
let globalMax = -Infinity;
|
|
427
|
+
for (const embedding of samples) {
|
|
428
|
+
const embArray = Array.from(embedding);
|
|
429
|
+
globalMin = Math.min(globalMin, Math.min(...embArray));
|
|
430
|
+
globalMax = Math.max(globalMax, Math.max(...embArray));
|
|
431
|
+
}
|
|
432
|
+
const padding = (globalMax - globalMin) * 0.1;
|
|
433
|
+
this.DEFAULT_MIN = globalMin - padding;
|
|
434
|
+
this.DEFAULT_MAX = globalMax + padding;
|
|
435
|
+
console.log(`Calibrated quantizer: [${this.DEFAULT_MIN.toFixed(3)}, ${this.DEFAULT_MAX.toFixed(3)}]`);
|
|
436
|
+
return { min: this.DEFAULT_MIN, max: this.DEFAULT_MAX };
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
function validateDataQuality(data) {
|
|
440
|
+
const issues = [];
|
|
441
|
+
let score = 100;
|
|
442
|
+
if (data.faceEmbedding.length < MIN_EMBEDDING_LENGTH) {
|
|
443
|
+
issues.push(`Embedding too short: ${data.faceEmbedding.length} < ${MIN_EMBEDDING_LENGTH}`);
|
|
444
|
+
score -= 30;
|
|
445
|
+
}
|
|
446
|
+
if (data.faceMesh.length > 0 && data.faceMesh.length < EXPECTED_MESH_POINTS) {
|
|
447
|
+
issues.push(`Incomplete mesh: ${data.faceMesh.length}/${EXPECTED_MESH_POINTS} points. (Ignored for performance)`);
|
|
448
|
+
}
|
|
449
|
+
if (data.faceLandmarks.length < EXPECTED_LANDMARKS) {
|
|
450
|
+
issues.push(`Incomplete landmarks: ${data.faceLandmarks.length}/${EXPECTED_LANDMARKS} points`);
|
|
451
|
+
score -= 15;
|
|
452
|
+
}
|
|
453
|
+
if (data.faceRotation) {
|
|
454
|
+
const { yaw, pitch, roll } = data.faceRotation.angle;
|
|
455
|
+
if (Math.abs(yaw) > MAX_ROTATION_YAW) {
|
|
456
|
+
issues.push(`Excessive yaw rotation: ${yaw.toFixed(1)}\xB0`);
|
|
457
|
+
score -= 10;
|
|
458
|
+
}
|
|
459
|
+
if (Math.abs(pitch) > MAX_ROTATION_PITCH) {
|
|
460
|
+
issues.push(`Excessive pitch tilt: ${pitch.toFixed(1)}\xB0`);
|
|
461
|
+
score -= 10;
|
|
462
|
+
}
|
|
463
|
+
if (Math.abs(roll) > MAX_ROTATION_ROLL) {
|
|
464
|
+
issues.push(`Excessive roll tilt: ${roll.toFixed(1)}\xB0`);
|
|
465
|
+
score -= 10;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (data.faceBox) {
|
|
469
|
+
const { width, height } = data.faceBox;
|
|
470
|
+
if (width < MIN_FACE_SIZE || height < MIN_FACE_SIZE) {
|
|
471
|
+
issues.push(`Face too small: ${Math.round(width)}x${Math.round(height)}px`);
|
|
472
|
+
score -= 15;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
score = Math.max(0, score);
|
|
476
|
+
return {
|
|
477
|
+
isValid: score >= MIN_QUALITY_SCORE,
|
|
478
|
+
score,
|
|
479
|
+
issues
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
function processFacialDataForAleo(face, livenessData, quantizer) {
|
|
483
|
+
const rawFacialData = extractRawFacialData(face);
|
|
484
|
+
const quality = validateDataQuality(rawFacialData);
|
|
485
|
+
if (!quality.isValid) {
|
|
486
|
+
console.warn("Data quality issues detected:", quality.issues);
|
|
487
|
+
}
|
|
488
|
+
const hasBlink = livenessData.blinkCount >= 1;
|
|
489
|
+
const hasEmotionTransition = livenessData.emotionTransitions.length > 0;
|
|
490
|
+
let livenessCombined = 0;
|
|
491
|
+
if (hasBlink && hasEmotionTransition) {
|
|
492
|
+
livenessCombined = 3;
|
|
493
|
+
} else if (hasBlink) {
|
|
494
|
+
livenessCombined = 1;
|
|
495
|
+
} else if (hasEmotionTransition) {
|
|
496
|
+
livenessCombined = 2;
|
|
497
|
+
}
|
|
498
|
+
const embeddingQuantizer = quantizer || new EmbeddingQuantizer();
|
|
499
|
+
const chunks = embeddingQuantizer.processForAleo(rawFacialData.faceEmbedding);
|
|
500
|
+
embeddingQuantizer.validateForAleo(chunks);
|
|
501
|
+
return {
|
|
502
|
+
...chunks,
|
|
503
|
+
liveness_combined: livenessCombined
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function generateBiometricSimHash(face, livenessData, averagedEmbedding) {
|
|
507
|
+
const rawData = extractRawFacialData(face);
|
|
508
|
+
const sourceEmbedding = averagedEmbedding && averagedEmbedding.length > 0 ? averagedEmbedding : rawData.faceEmbedding;
|
|
509
|
+
const normalized = normalizeEmbedding(sourceEmbedding);
|
|
510
|
+
const hyperplanes = getHyperplanes(1024, 256);
|
|
511
|
+
const simHashHex = computeSimHash(normalized, hyperplanes);
|
|
512
|
+
const hasBlink = livenessData.blinkCount >= 1;
|
|
513
|
+
const hasEmotionTransition = livenessData.emotionTransitions.length > 0;
|
|
514
|
+
let livenessCombined = 0;
|
|
515
|
+
if (hasBlink && hasEmotionTransition) {
|
|
516
|
+
livenessCombined = 3;
|
|
517
|
+
} else if (hasBlink) {
|
|
518
|
+
livenessCombined = 1;
|
|
519
|
+
} else if (hasEmotionTransition) {
|
|
520
|
+
livenessCombined = 2;
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
simhash_full: simHashHex,
|
|
524
|
+
liveness_combined: livenessCombined
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function generateRandomSalt() {
|
|
528
|
+
const array = new Uint32Array(8);
|
|
529
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
530
|
+
crypto.getRandomValues(array);
|
|
531
|
+
} else {
|
|
532
|
+
for (let i = 0; i < array.length; i++) {
|
|
533
|
+
array[i] = Math.floor(Math.random() * 4294967295);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return Array.from(array).map((val) => val.toString(16).padStart(8, "0")).join("");
|
|
537
|
+
}
|
|
538
|
+
function createCalibratedQuantizer(samples) {
|
|
539
|
+
const quantizer = new EmbeddingQuantizer();
|
|
540
|
+
quantizer.calibrateFromSamples(samples);
|
|
541
|
+
return quantizer;
|
|
542
|
+
}
|
|
543
|
+
function calculateHammingDistance(hash1, hash2) {
|
|
544
|
+
return hammingDistance(hash1, hash2);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/core/biometrics/faceOcclusion.ts
|
|
548
|
+
function lumaOf(r, g, b) {
|
|
549
|
+
return 0.299 * r + 0.587 * g + 0.114 * b;
|
|
550
|
+
}
|
|
551
|
+
function chromaDist(a, b) {
|
|
552
|
+
const sumA = a.r + a.g + a.b || 1;
|
|
553
|
+
const sumB = b.r + b.g + b.b || 1;
|
|
554
|
+
const rA = a.r / sumA, gA = a.g / sumA;
|
|
555
|
+
const rB = b.r / sumB, gB = b.g / sumB;
|
|
556
|
+
return Math.sqrt((rA - rB) ** 2 + (gA - gB) ** 2);
|
|
557
|
+
}
|
|
558
|
+
function samplePoint(ctx, landmarks, index, width, height, radius = 3) {
|
|
559
|
+
const pt = landmarks[index];
|
|
560
|
+
if (!pt) return { r: 128, g: 96, b: 80, luma: 100 };
|
|
561
|
+
const cx = Math.max(0, Math.min(width - 1, Math.floor(pt.x * width)));
|
|
562
|
+
const cy = Math.max(0, Math.min(height - 1, Math.floor(pt.y * height)));
|
|
563
|
+
const x0 = Math.max(0, cx - radius);
|
|
564
|
+
const y0 = Math.max(0, cy - radius);
|
|
565
|
+
const bw = Math.min(width - x0, radius * 2 + 1);
|
|
566
|
+
const bh = Math.min(height - y0, radius * 2 + 1);
|
|
567
|
+
if (bw < 1 || bh < 1) return { r: 128, g: 96, b: 80, luma: 100 };
|
|
568
|
+
const data = ctx.getImageData(x0, y0, bw, bh).data;
|
|
569
|
+
let r = 0, g = 0, b = 0;
|
|
570
|
+
const n = data.length / 4;
|
|
571
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
572
|
+
r += data[i];
|
|
573
|
+
g += data[i + 1];
|
|
574
|
+
b += data[i + 2];
|
|
575
|
+
}
|
|
576
|
+
r /= n;
|
|
577
|
+
g /= n;
|
|
578
|
+
b /= n;
|
|
579
|
+
return { r, g, b, luma: lumaOf(r, g, b) };
|
|
580
|
+
}
|
|
581
|
+
function sampleRect(ctx, x0, y0, w, h, imgW, imgH) {
|
|
582
|
+
x0 = Math.max(0, Math.round(x0));
|
|
583
|
+
y0 = Math.max(0, Math.round(y0));
|
|
584
|
+
w = Math.min(imgW - x0, Math.max(1, Math.round(w)));
|
|
585
|
+
h = Math.min(imgH - y0, Math.max(1, Math.round(h)));
|
|
586
|
+
const data = ctx.getImageData(x0, y0, w, h).data;
|
|
587
|
+
let r = 0, g = 0, b = 0;
|
|
588
|
+
const n = data.length / 4;
|
|
589
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
590
|
+
r += data[i];
|
|
591
|
+
g += data[i + 1];
|
|
592
|
+
b += data[i + 2];
|
|
593
|
+
}
|
|
594
|
+
r /= n;
|
|
595
|
+
g /= n;
|
|
596
|
+
b /= n;
|
|
597
|
+
return { r, g, b, luma: lumaOf(r, g, b) };
|
|
598
|
+
}
|
|
599
|
+
function sobelEdgeDensity(ctx, x0, y0, w, h, imgW, imgH, threshold = 30) {
|
|
600
|
+
x0 = Math.max(0, Math.round(x0));
|
|
601
|
+
y0 = Math.max(0, Math.round(y0));
|
|
602
|
+
w = Math.min(imgW - x0, Math.max(3, Math.round(w)));
|
|
603
|
+
h = Math.min(imgH - y0, Math.max(3, Math.round(h)));
|
|
604
|
+
const data = ctx.getImageData(x0, y0, w, h).data;
|
|
605
|
+
const grey = new Float32Array(w * h);
|
|
606
|
+
for (let i = 0; i < grey.length; i++) {
|
|
607
|
+
grey[i] = lumaOf(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]);
|
|
608
|
+
}
|
|
609
|
+
let edgePx = 0;
|
|
610
|
+
for (let y = 1; y < h - 1; y++) {
|
|
611
|
+
for (let x = 1; x < w - 1; x++) {
|
|
612
|
+
const idx = y * w + x;
|
|
613
|
+
const gx = -grey[idx - w - 1] + grey[idx - w + 1] - 2 * grey[idx - 1] + 2 * grey[idx + 1] - grey[idx + w - 1] + grey[idx + w + 1];
|
|
614
|
+
const gy = grey[idx - w - 1] + 2 * grey[idx - w] + grey[idx - w + 1] - grey[idx + w - 1] - 2 * grey[idx + w] - grey[idx + w + 1];
|
|
615
|
+
if (Math.sqrt(gx * gx + gy * gy) > threshold) edgePx++;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return edgePx / ((w - 2) * (h - 2));
|
|
619
|
+
}
|
|
620
|
+
function countSpecularBlobs(ctx, x0, y0, w, h, imgW, imgH, lumaThreshold = 220, minSepFraction = 0.15) {
|
|
621
|
+
x0 = Math.max(0, Math.round(x0));
|
|
622
|
+
y0 = Math.max(0, Math.round(y0));
|
|
623
|
+
w = Math.min(imgW - x0, Math.max(1, Math.round(w)));
|
|
624
|
+
h = Math.min(imgH - y0, Math.max(1, Math.round(h)));
|
|
625
|
+
const data = ctx.getImageData(x0, y0, w, h).data;
|
|
626
|
+
const brightCentroids = [];
|
|
627
|
+
const minSep = w * minSepFraction;
|
|
628
|
+
for (let row = 0; row < h; row++) {
|
|
629
|
+
for (let col = 0; col < w; col++) {
|
|
630
|
+
const i = (row * w + col) * 4;
|
|
631
|
+
const l = lumaOf(data[i], data[i + 1], data[i + 2]);
|
|
632
|
+
if (l < lumaThreshold) continue;
|
|
633
|
+
let merged = false;
|
|
634
|
+
for (const c of brightCentroids) {
|
|
635
|
+
const d = Math.hypot(col - c.x, row - c.y);
|
|
636
|
+
if (d < minSep) {
|
|
637
|
+
merged = true;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (!merged) brightCentroids.push({ x: col, y: row });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return brightCentroids.length;
|
|
645
|
+
}
|
|
646
|
+
function irisLumaVariance(ctx, landmarks, irisIndices, imgW, imgH) {
|
|
647
|
+
if (landmarks.length < 478) return -1;
|
|
648
|
+
const valid = irisIndices.filter((i) => landmarks[i]);
|
|
649
|
+
if (valid.length < 3) return -1;
|
|
650
|
+
const xs = valid.map((i) => landmarks[i].x * imgW);
|
|
651
|
+
const ys = valid.map((i) => landmarks[i].y * imgH);
|
|
652
|
+
const cx = xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
653
|
+
const cy = ys.reduce((a, b) => a + b, 0) / ys.length;
|
|
654
|
+
const r = Math.max(4, xs.reduce((a, x, i) => a + Math.hypot(x - cx, ys[i] - cy), 0) / xs.length);
|
|
655
|
+
const x0 = Math.max(0, Math.floor(cx - r));
|
|
656
|
+
const y0 = Math.max(0, Math.floor(cy - r));
|
|
657
|
+
const bw = Math.min(imgW - x0, Math.ceil(r * 2));
|
|
658
|
+
const bh = Math.min(imgH - y0, Math.ceil(r * 2));
|
|
659
|
+
if (bw < 3 || bh < 3) return -1;
|
|
660
|
+
const data = ctx.getImageData(x0, y0, bw, bh).data;
|
|
661
|
+
const lumas = [];
|
|
662
|
+
for (let row = 0; row < bh; row++) {
|
|
663
|
+
for (let col = 0; col < bw; col++) {
|
|
664
|
+
const dx = x0 + col - cx, dy = y0 + row - cy;
|
|
665
|
+
if (dx * dx + dy * dy > r * r) continue;
|
|
666
|
+
const i = (row * bw + col) * 4;
|
|
667
|
+
lumas.push(lumaOf(data[i], data[i + 1], data[i + 2]));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (lumas.length < 5) return -1;
|
|
671
|
+
const mean = lumas.reduce((a, v) => a + v, 0) / lumas.length;
|
|
672
|
+
return lumas.reduce((a, v) => a + (v - mean) ** 2, 0) / lumas.length;
|
|
673
|
+
}
|
|
674
|
+
function computeEyeSignals(side, skin, ctx, landmarks, imgW, imgH, duringFlash) {
|
|
675
|
+
const isLeft = side === "left";
|
|
676
|
+
const outerIdx = isLeft ? 33 : 263;
|
|
677
|
+
const innerIdx = isLeft ? 133 : 362;
|
|
678
|
+
const upperLidIdx = isLeft ? 159 : 386;
|
|
679
|
+
const lowerLidIdx = isLeft ? 145 : 374;
|
|
680
|
+
const browIdx = isLeft ? 70 : 300;
|
|
681
|
+
const cheekIdx = isLeft ? 116 : 345;
|
|
682
|
+
const irisIdxs = isLeft ? [474, 475, 476, 477] : [469, 470, 471, 472];
|
|
683
|
+
const outer = landmarks[outerIdx];
|
|
684
|
+
const inner = landmarks[innerIdx];
|
|
685
|
+
const upperLid = landmarks[upperLidIdx];
|
|
686
|
+
const lowerLid = landmarks[lowerLidIdx];
|
|
687
|
+
if (!outer || !inner || !upperLid || !lowerLid) {
|
|
688
|
+
return { lensOpacity: false, frameEdgeDensity: false, specularClusters: false, chromaStripe: false, irisTextureMismatch: false };
|
|
689
|
+
}
|
|
690
|
+
const eyeLeft = Math.min(outer.x, inner.x) * imgW;
|
|
691
|
+
const eyeRight = Math.max(outer.x, inner.x) * imgW;
|
|
692
|
+
const eyeTop = upperLid.y * imgH - 4;
|
|
693
|
+
const eyeBottom = lowerLid.y * imgH + 4;
|
|
694
|
+
const eyeW = eyeRight - eyeLeft;
|
|
695
|
+
const eyeH = Math.max(6, eyeBottom - eyeTop);
|
|
696
|
+
const lensY0 = (upperLid.y * imgH + eyeTop) / 2;
|
|
697
|
+
const lensH = Math.max(6, lowerLid.y * imgH - lensY0);
|
|
698
|
+
const lensSample = sampleRect(ctx, eyeLeft + eyeW * 0.1, lensY0, eyeW * 0.8, lensH, imgW, imgH);
|
|
699
|
+
const lensOpacity = skin.luma > 40 && lensSample.luma < skin.luma * 0.38;
|
|
700
|
+
const brow = landmarks[browIdx];
|
|
701
|
+
const rawStripTop = brow ? brow.y * imgH : eyeTop - 14;
|
|
702
|
+
const lidY = upperLid.y * imgH;
|
|
703
|
+
const stripTop = rawStripTop + (lidY - rawStripTop) * 0.7;
|
|
704
|
+
const stripBottom = lidY + 2;
|
|
705
|
+
const stripH = Math.max(4, stripBottom - stripTop);
|
|
706
|
+
const edgeDensity = sobelEdgeDensity(
|
|
707
|
+
ctx,
|
|
708
|
+
eyeLeft - 4,
|
|
709
|
+
stripTop,
|
|
710
|
+
eyeW + 8,
|
|
711
|
+
stripH,
|
|
712
|
+
imgW,
|
|
713
|
+
imgH,
|
|
714
|
+
32
|
|
715
|
+
// higher threshold — we want frame-weight edges, not fine skin texture
|
|
716
|
+
);
|
|
717
|
+
const frameEdgeDensity = edgeDensity > 0.3;
|
|
718
|
+
let specularClusters = false;
|
|
719
|
+
if (!duringFlash) {
|
|
720
|
+
const blobs = countSpecularBlobs(
|
|
721
|
+
ctx,
|
|
722
|
+
eyeLeft - 2,
|
|
723
|
+
eyeTop - 2,
|
|
724
|
+
eyeW + 4,
|
|
725
|
+
eyeH + 4,
|
|
726
|
+
imgW,
|
|
727
|
+
imgH,
|
|
728
|
+
230,
|
|
729
|
+
// raised from 215
|
|
730
|
+
0.2
|
|
731
|
+
// raised from 0.14
|
|
732
|
+
);
|
|
733
|
+
specularClusters = blobs >= 2;
|
|
734
|
+
}
|
|
735
|
+
let chromaStripe = false;
|
|
736
|
+
{
|
|
737
|
+
const brow2 = landmarks[browIdx];
|
|
738
|
+
const lidTop = upperLid.y * imgH;
|
|
739
|
+
const browBottom = brow2 ? brow2.y * imgH : lidTop - 14;
|
|
740
|
+
const frameZoneY1 = browBottom + (lidTop - browBottom) * 0.4;
|
|
741
|
+
const frameZoneY2 = browBottom + (lidTop - browBottom) * 0.8;
|
|
742
|
+
const xPositions = [0.15, 0.35, 0.5, 0.65, 0.85].map((f) => eyeLeft + eyeW * f);
|
|
743
|
+
let stripeHits = 0;
|
|
744
|
+
for (const x of xPositions) {
|
|
745
|
+
const midSample = sampleRect(ctx, x - 2, frameZoneY1 - 3, 5, 6, imgW, imgH);
|
|
746
|
+
const nearSample = sampleRect(ctx, x - 2, frameZoneY2 - 3, 5, 6, imgW, imgH);
|
|
747
|
+
if (chromaDist(midSample, skin) > 0.17 && chromaDist(nearSample, skin) > 0.17) {
|
|
748
|
+
stripeHits++;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
chromaStripe = stripeHits >= 4;
|
|
752
|
+
}
|
|
753
|
+
let irisTextureMismatch = false;
|
|
754
|
+
{
|
|
755
|
+
const openness = (lowerLid.y - upperLid.y) * imgH;
|
|
756
|
+
if (openness > imgH * 0.025) {
|
|
757
|
+
const variance = irisLumaVariance(ctx, landmarks, irisIdxs, imgW, imgH);
|
|
758
|
+
if (variance >= 0 && variance < 60) {
|
|
759
|
+
irisTextureMismatch = true;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return { lensOpacity, frameEdgeDensity, specularClusters, chromaStripe, irisTextureMismatch };
|
|
764
|
+
}
|
|
765
|
+
function computeBridgeSignal(skin, ctx, landmarks, imgW, imgH) {
|
|
766
|
+
const bridgePt = landmarks[168];
|
|
767
|
+
if (!bridgePt) return { chromaHigh: false, edgeHigh: false };
|
|
768
|
+
const bx = bridgePt.x * imgW;
|
|
769
|
+
const by = bridgePt.y * imgH;
|
|
770
|
+
const bSample = sampleRect(ctx, bx - 12, by - 5, 24, 10, imgW, imgH);
|
|
771
|
+
const chromaHigh = chromaDist(bSample, skin) > 0.14 || bSample.luma < skin.luma * 0.38 && skin.luma > 40;
|
|
772
|
+
const edgeDensity = sobelEdgeDensity(ctx, bx - 14, by - 7, 28, 14, imgW, imgH, 30);
|
|
773
|
+
const edgeHigh = edgeDensity > 0.2;
|
|
774
|
+
return { chromaHigh, edgeHigh };
|
|
775
|
+
}
|
|
776
|
+
var EMA_ALPHA = 0.35;
|
|
777
|
+
var CONFIRM_FRAMES = 3;
|
|
778
|
+
function emptyEma() {
|
|
779
|
+
return { lensOpacity: 0, frameEdgeDensity: 0, specularClusters: 0, chromaStripe: 0, irisTextureMismatch: 0, bridgeChroma: 0, bridgeEdge: 0 };
|
|
780
|
+
}
|
|
781
|
+
var GlassesDetector = class {
|
|
782
|
+
constructor() {
|
|
783
|
+
this.emaLeft = emptyEma();
|
|
784
|
+
this.emaRight = emptyEma();
|
|
785
|
+
this.emaBridge = { bridgeChroma: 0, bridgeEdge: 0 };
|
|
786
|
+
this.consecutivePositive = 0;
|
|
787
|
+
this.consecutiveNegative = 0;
|
|
788
|
+
this._detected = false;
|
|
789
|
+
}
|
|
790
|
+
reset() {
|
|
791
|
+
this.emaLeft = emptyEma();
|
|
792
|
+
this.emaRight = emptyEma();
|
|
793
|
+
this.emaBridge = { bridgeChroma: 0, bridgeEdge: 0 };
|
|
794
|
+
this.consecutivePositive = 0;
|
|
795
|
+
this.consecutiveNegative = 0;
|
|
796
|
+
this._detected = false;
|
|
797
|
+
}
|
|
798
|
+
get detected() {
|
|
799
|
+
return this._detected;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Feed a new frame into the detector.
|
|
803
|
+
* @returns true if glasses are currently detected (after smoothing).
|
|
804
|
+
*/
|
|
805
|
+
update(skin, ctx, landmarks, imgW, imgH, options = {}) {
|
|
806
|
+
if (skin.luma < 18) return this._detected;
|
|
807
|
+
const flash = options.duringLivenessFlash ?? false;
|
|
808
|
+
const lSig = computeEyeSignals("left", skin, ctx, landmarks, imgW, imgH, flash);
|
|
809
|
+
const rSig = computeEyeSignals("right", skin, ctx, landmarks, imgW, imgH, flash);
|
|
810
|
+
const bSig = computeBridgeSignal(skin, ctx, landmarks, imgW, imgH);
|
|
811
|
+
const a = EMA_ALPHA;
|
|
812
|
+
const ema = (prev, val) => prev * (1 - a) + (val ? 1 : 0) * a;
|
|
813
|
+
this.emaLeft.lensOpacity = ema(this.emaLeft.lensOpacity, lSig.lensOpacity);
|
|
814
|
+
this.emaLeft.frameEdgeDensity = ema(this.emaLeft.frameEdgeDensity, lSig.frameEdgeDensity);
|
|
815
|
+
this.emaLeft.specularClusters = ema(this.emaLeft.specularClusters, lSig.specularClusters);
|
|
816
|
+
this.emaLeft.chromaStripe = ema(this.emaLeft.chromaStripe, lSig.chromaStripe);
|
|
817
|
+
this.emaLeft.irisTextureMismatch = ema(this.emaLeft.irisTextureMismatch, lSig.irisTextureMismatch);
|
|
818
|
+
this.emaRight.lensOpacity = ema(this.emaRight.lensOpacity, rSig.lensOpacity);
|
|
819
|
+
this.emaRight.frameEdgeDensity = ema(this.emaRight.frameEdgeDensity, rSig.frameEdgeDensity);
|
|
820
|
+
this.emaRight.specularClusters = ema(this.emaRight.specularClusters, rSig.specularClusters);
|
|
821
|
+
this.emaRight.chromaStripe = ema(this.emaRight.chromaStripe, rSig.chromaStripe);
|
|
822
|
+
this.emaRight.irisTextureMismatch = ema(this.emaRight.irisTextureMismatch, rSig.irisTextureMismatch);
|
|
823
|
+
this.emaBridge.bridgeChroma = ema(this.emaBridge.bridgeChroma, bSig.chromaHigh);
|
|
824
|
+
this.emaBridge.bridgeEdge = ema(this.emaBridge.bridgeEdge, bSig.edgeHigh);
|
|
825
|
+
const T = 0.45;
|
|
826
|
+
const score = (e) => (e.lensOpacity > T ? 1 : 0) + (e.frameEdgeDensity > T ? 1 : 0) + (e.specularClusters > T ? 1 : 0) + (e.chromaStripe > T ? 1 : 0) + (e.irisTextureMismatch > T ? 1 : 0);
|
|
827
|
+
const leftScore = score(this.emaLeft);
|
|
828
|
+
const rightScore = score(this.emaRight);
|
|
829
|
+
const bridgeScore = (this.emaBridge.bridgeChroma > T ? 1 : 0) + (this.emaBridge.bridgeEdge > T ? 1 : 0);
|
|
830
|
+
const sunglasses = this.emaLeft.lensOpacity > T && this.emaRight.lensOpacity > T;
|
|
831
|
+
const clearGlasses = leftScore >= 2 && rightScore >= 2 && leftScore + rightScore >= 5;
|
|
832
|
+
const rimless = leftScore >= 2 && rightScore >= 2 && bridgeScore >= 1 && leftScore + rightScore + bridgeScore >= 6;
|
|
833
|
+
const isGlasses = sunglasses || clearGlasses || rimless;
|
|
834
|
+
if (isGlasses) {
|
|
835
|
+
this.consecutivePositive++;
|
|
836
|
+
this.consecutiveNegative = 0;
|
|
837
|
+
if (this.consecutivePositive >= CONFIRM_FRAMES) this._detected = true;
|
|
838
|
+
} else {
|
|
839
|
+
this.consecutiveNegative++;
|
|
840
|
+
this.consecutivePositive = 0;
|
|
841
|
+
if (this.consecutiveNegative >= 5) this._detected = false;
|
|
842
|
+
}
|
|
843
|
+
return this._detected;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
function computeMaskSignals(skin, ctx, landmarks, imgW, imgH) {
|
|
847
|
+
const forehead = samplePoint(ctx, landmarks, 10, imgW, imgH, 5);
|
|
848
|
+
const noseTip = samplePoint(ctx, landmarks, 4, imgW, imgH, 4);
|
|
849
|
+
const noseBottom = samplePoint(ctx, landmarks, 2, imgW, imgH, 4);
|
|
850
|
+
const upperLipCentre = samplePoint(ctx, landmarks, 13, imgW, imgH, 4);
|
|
851
|
+
const lowerLipCentre = samplePoint(ctx, landmarks, 14, imgW, imgH, 4);
|
|
852
|
+
const chin = samplePoint(ctx, landmarks, 152, imgW, imgH, 5);
|
|
853
|
+
const leftMouth = samplePoint(ctx, landmarks, 61, imgW, imgH, 4);
|
|
854
|
+
const rightMouth = samplePoint(ctx, landmarks, 291, imgW, imgH, 4);
|
|
855
|
+
const leftLowerCheek = samplePoint(ctx, landmarks, 50, imgW, imgH, 5);
|
|
856
|
+
const rightLowerCheek = samplePoint(ctx, landmarks, 280, imgW, imgH, 5);
|
|
857
|
+
const leftJaw = samplePoint(ctx, landmarks, 172, imgW, imgH, 5);
|
|
858
|
+
const rightJaw = samplePoint(ctx, landmarks, 397, imgW, imgH, 5);
|
|
859
|
+
const lowerPoints = [noseTip, noseBottom, upperLipCentre, lowerLipCentre, chin, leftLowerCheek, rightLowerCheek];
|
|
860
|
+
const meanLowerChroma = lowerPoints.reduce((s, p) => s + chromaDist(p, forehead), 0) / lowerPoints.length;
|
|
861
|
+
const meanLowerR = lowerPoints.reduce((s, p) => s + p.r, 0) / lowerPoints.length;
|
|
862
|
+
const meanLowerG = lowerPoints.reduce((s, p) => s + p.g, 0) / lowerPoints.length;
|
|
863
|
+
const meanLowerB = lowerPoints.reduce((s, p) => s + p.b, 0) / lowerPoints.length;
|
|
864
|
+
const lowerVariance = lowerPoints.reduce(
|
|
865
|
+
(s, p) => s + (p.r - meanLowerR) ** 2 + (p.g - meanLowerG) ** 2 + (p.b - meanLowerB) ** 2,
|
|
866
|
+
0
|
|
867
|
+
) / lowerPoints.length;
|
|
868
|
+
const lowerFaceChromaUniform = meanLowerChroma > 0.1 && lowerVariance < 400;
|
|
869
|
+
const lumas = lowerPoints.map((p) => p.luma);
|
|
870
|
+
const meanLuma = lumas.reduce((a, v) => a + v, 0) / lumas.length;
|
|
871
|
+
const lumaStd = Math.sqrt(lumas.reduce((a, v) => a + (v - meanLuma) ** 2, 0) / lumas.length);
|
|
872
|
+
const lowerFaceLumaFlat = lumaStd < 10 && Math.abs(meanLuma - skin.luma) > 15;
|
|
873
|
+
const nLm = landmarks[4];
|
|
874
|
+
const cLm = landmarks[50];
|
|
875
|
+
let textureSuppressionNose = false;
|
|
876
|
+
if (nLm && cLm && skin.luma > 35) {
|
|
877
|
+
const rx = Math.min(nLm.x, cLm.x) * imgW - 4;
|
|
878
|
+
const ry = nLm.y * imgH;
|
|
879
|
+
const rw = Math.abs(nLm.x - cLm.x) * imgW + 8;
|
|
880
|
+
const rh = (cLm.y - nLm.y) * imgH + 8;
|
|
881
|
+
const density = sobelEdgeDensity(ctx, rx, ry, rw, rh, imgW, imgH, 20);
|
|
882
|
+
textureSuppressionNose = density < 0.06;
|
|
883
|
+
}
|
|
884
|
+
const lipRedness = (upperLipCentre.r + lowerLipCentre.r) / Math.max(1, upperLipCentre.g + upperLipCentre.b + lowerLipCentre.g + lowerLipCentre.b);
|
|
885
|
+
const skinRedness = skin.r / Math.max(1, skin.g + skin.b);
|
|
886
|
+
const lipColourMismatch = skin.luma > 50 && lipRedness < skinRedness - 0.03;
|
|
887
|
+
let lowerFaceEdgeDensity = false;
|
|
888
|
+
{
|
|
889
|
+
const lJaw = landmarks[172];
|
|
890
|
+
const rJaw = landmarks[397];
|
|
891
|
+
if (lJaw && rJaw) {
|
|
892
|
+
const stripY = (lJaw.y + rJaw.y) / 2 * imgH - 8;
|
|
893
|
+
const stripX = lJaw.x * imgW;
|
|
894
|
+
const stripW = (rJaw.x - lJaw.x) * imgW;
|
|
895
|
+
const stripH = 16;
|
|
896
|
+
const density = sobelEdgeDensity(ctx, stripX, stripY, stripW, stripH, imgW, imgH, 24);
|
|
897
|
+
lowerFaceEdgeDensity = density > 0.28;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const lumaRatio = forehead.luma > 10 ? meanLuma / forehead.luma : 1;
|
|
901
|
+
const lumaRatioAnomaly = lumaRatio < 0.5 || lumaRatio > 1.35;
|
|
902
|
+
return {
|
|
903
|
+
lowerFaceChromaUniform,
|
|
904
|
+
lowerFaceLumaFlat,
|
|
905
|
+
textureSuppressionNose,
|
|
906
|
+
lipColourMismatch,
|
|
907
|
+
lowerFaceEdgeDensity,
|
|
908
|
+
lumaRatioAnomaly
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
var MaskDetector = class {
|
|
912
|
+
constructor() {
|
|
913
|
+
this.ema = {
|
|
914
|
+
lowerFaceChromaUniform: 0,
|
|
915
|
+
lowerFaceLumaFlat: 0,
|
|
916
|
+
textureSuppressionNose: 0,
|
|
917
|
+
lipColourMismatch: 0,
|
|
918
|
+
lowerFaceEdgeDensity: 0,
|
|
919
|
+
lumaRatioAnomaly: 0
|
|
920
|
+
};
|
|
921
|
+
this.consecutivePositive = 0;
|
|
922
|
+
this.consecutiveNegative = 0;
|
|
923
|
+
this._detected = false;
|
|
924
|
+
}
|
|
925
|
+
reset() {
|
|
926
|
+
for (const k of Object.keys(this.ema)) this.ema[k] = 0;
|
|
927
|
+
this.consecutivePositive = 0;
|
|
928
|
+
this.consecutiveNegative = 0;
|
|
929
|
+
this._detected = false;
|
|
930
|
+
}
|
|
931
|
+
get detected() {
|
|
932
|
+
return this._detected;
|
|
933
|
+
}
|
|
934
|
+
update(skin, ctx, landmarks, imgW, imgH) {
|
|
935
|
+
if (skin.luma < 18) return this._detected;
|
|
936
|
+
const sig = computeMaskSignals(skin, ctx, landmarks, imgW, imgH);
|
|
937
|
+
const a = EMA_ALPHA;
|
|
938
|
+
const ema = (prev, val) => prev * (1 - a) + (val ? 1 : 0) * a;
|
|
939
|
+
this.ema.lowerFaceChromaUniform = ema(this.ema.lowerFaceChromaUniform, sig.lowerFaceChromaUniform);
|
|
940
|
+
this.ema.lowerFaceLumaFlat = ema(this.ema.lowerFaceLumaFlat, sig.lowerFaceLumaFlat);
|
|
941
|
+
this.ema.textureSuppressionNose = ema(this.ema.textureSuppressionNose, sig.textureSuppressionNose);
|
|
942
|
+
this.ema.lipColourMismatch = ema(this.ema.lipColourMismatch, sig.lipColourMismatch);
|
|
943
|
+
this.ema.lowerFaceEdgeDensity = ema(this.ema.lowerFaceEdgeDensity, sig.lowerFaceEdgeDensity);
|
|
944
|
+
this.ema.lumaRatioAnomaly = ema(this.ema.lumaRatioAnomaly, sig.lumaRatioAnomaly);
|
|
945
|
+
const T = 0.45;
|
|
946
|
+
const totalScore = (this.ema.lowerFaceChromaUniform > T ? 1 : 0) + (this.ema.lowerFaceLumaFlat > T ? 1 : 0) + (this.ema.textureSuppressionNose > T ? 1 : 0) + (this.ema.lipColourMismatch > T ? 1 : 0) + (this.ema.lowerFaceEdgeDensity > T ? 1 : 0) + (this.ema.lumaRatioAnomaly > T ? 1 : 0);
|
|
947
|
+
const isMask = totalScore >= 3;
|
|
948
|
+
if (isMask) {
|
|
949
|
+
this.consecutivePositive++;
|
|
950
|
+
this.consecutiveNegative = 0;
|
|
951
|
+
if (this.consecutivePositive >= CONFIRM_FRAMES) this._detected = true;
|
|
952
|
+
} else {
|
|
953
|
+
this.consecutiveNegative++;
|
|
954
|
+
this.consecutivePositive = 0;
|
|
955
|
+
if (this.consecutiveNegative >= 5) this._detected = false;
|
|
956
|
+
}
|
|
957
|
+
return this._detected;
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
var FaceOcclusionTracker = class {
|
|
961
|
+
constructor() {
|
|
962
|
+
this.armed = false;
|
|
963
|
+
this.blocked = false;
|
|
964
|
+
this.obstructionFrames = 0;
|
|
965
|
+
this.clearFrames = 0;
|
|
966
|
+
this.sessionViolation = false;
|
|
967
|
+
this.lastMessage = "Please remove obstructions from your face.";
|
|
968
|
+
this.preArmBlockFrames = 4;
|
|
969
|
+
this.violationAfterFrames = 1;
|
|
970
|
+
this.releaseFrames = 4;
|
|
971
|
+
}
|
|
972
|
+
reset() {
|
|
973
|
+
this.armed = false;
|
|
974
|
+
this.blocked = false;
|
|
975
|
+
this.obstructionFrames = 0;
|
|
976
|
+
this.clearFrames = 0;
|
|
977
|
+
this.sessionViolation = false;
|
|
978
|
+
this.lastMessage = "Please remove obstructions from your face.";
|
|
979
|
+
}
|
|
980
|
+
arm() {
|
|
981
|
+
this.armed = true;
|
|
982
|
+
this.obstructionFrames = 0;
|
|
983
|
+
}
|
|
984
|
+
hasSessionViolation() {
|
|
985
|
+
return this.sessionViolation;
|
|
986
|
+
}
|
|
987
|
+
apply(raw) {
|
|
988
|
+
const isObstruction = !raw.ok && (raw.message?.toLowerCase().includes("mask") || raw.message?.toLowerCase().includes("glass"));
|
|
989
|
+
if (isObstruction) {
|
|
990
|
+
this.obstructionFrames++;
|
|
991
|
+
this.clearFrames = 0;
|
|
992
|
+
if (raw.message) this.lastMessage = raw.message;
|
|
993
|
+
const blockThreshold = this.armed ? 1 : this.preArmBlockFrames;
|
|
994
|
+
if (this.obstructionFrames >= blockThreshold) this.blocked = true;
|
|
995
|
+
if (this.armed && this.obstructionFrames >= this.violationAfterFrames) this.sessionViolation = true;
|
|
996
|
+
if (this.blocked) return { ok: false, message: this.lastMessage };
|
|
997
|
+
return { ok: true };
|
|
998
|
+
}
|
|
999
|
+
this.obstructionFrames = 0;
|
|
1000
|
+
if (this.blocked) {
|
|
1001
|
+
if (!raw.ok) return raw;
|
|
1002
|
+
this.clearFrames++;
|
|
1003
|
+
if (this.clearFrames >= this.releaseFrames) {
|
|
1004
|
+
this.blocked = false;
|
|
1005
|
+
this.clearFrames = 0;
|
|
1006
|
+
return { ok: true };
|
|
1007
|
+
}
|
|
1008
|
+
return { ok: false, message: this.lastMessage };
|
|
1009
|
+
}
|
|
1010
|
+
return raw;
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
var _glassesDetector = new GlassesDetector();
|
|
1014
|
+
var _maskDetector = new MaskDetector();
|
|
1015
|
+
var _noLandmarkFrames = 0;
|
|
1016
|
+
function checkFaceOcclusionRaw(landmarks, ctx, width, height, options = {}) {
|
|
1017
|
+
if (!landmarks.length) {
|
|
1018
|
+
_noLandmarkFrames++;
|
|
1019
|
+
if (_noLandmarkFrames > 10) {
|
|
1020
|
+
_glassesDetector.reset();
|
|
1021
|
+
_maskDetector.reset();
|
|
1022
|
+
}
|
|
1023
|
+
return { ok: false, message: "Face not detected." };
|
|
1024
|
+
}
|
|
1025
|
+
_noLandmarkFrames = 0;
|
|
1026
|
+
const forehead = samplePoint(ctx, landmarks, 10, width, height, 5);
|
|
1027
|
+
const leftCheek = samplePoint(ctx, landmarks, 116, width, height, 5);
|
|
1028
|
+
const rightCheek = samplePoint(ctx, landmarks, 345, width, height, 5);
|
|
1029
|
+
const skin = {
|
|
1030
|
+
r: (leftCheek.r + rightCheek.r + forehead.r) / 3,
|
|
1031
|
+
g: (leftCheek.g + rightCheek.g + forehead.g) / 3,
|
|
1032
|
+
b: (leftCheek.b + rightCheek.b + forehead.b) / 3,
|
|
1033
|
+
luma: (leftCheek.luma + rightCheek.luma + forehead.luma) / 3
|
|
1034
|
+
};
|
|
1035
|
+
const maskDetected = _maskDetector.update(skin, ctx, landmarks, width, height);
|
|
1036
|
+
if (maskDetected) {
|
|
1037
|
+
return { ok: false, message: "Please remove your face mask." };
|
|
1038
|
+
}
|
|
1039
|
+
const glassesDetected = _glassesDetector.update(skin, ctx, landmarks, width, height, options);
|
|
1040
|
+
if (glassesDetected) {
|
|
1041
|
+
return { ok: false, message: "Please remove your glasses." };
|
|
1042
|
+
}
|
|
1043
|
+
return { ok: true };
|
|
1044
|
+
}
|
|
1045
|
+
function checkFaceOcclusion(landmarks, ctx, width, height) {
|
|
1046
|
+
return checkFaceOcclusionRaw(landmarks, ctx, width, height);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/core/biometrics/LivenessEngine.ts
|
|
1050
|
+
var LivenessEngine = class {
|
|
1051
|
+
constructor() {
|
|
1052
|
+
this.videoElement = null;
|
|
1053
|
+
this.flashOverlay = null;
|
|
1054
|
+
// Track average intensity of R, G, B channels across frames
|
|
1055
|
+
this.colorHistory = [];
|
|
1056
|
+
this.canvas = document.createElement("canvas");
|
|
1057
|
+
this.ctx = this.canvas.getContext("2d", { willReadFrequently: true });
|
|
1058
|
+
}
|
|
1059
|
+
setVideoElement(video) {
|
|
1060
|
+
this.videoElement = video;
|
|
1061
|
+
}
|
|
1062
|
+
setFlashOverlay(overlay) {
|
|
1063
|
+
this.flashOverlay = overlay;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Run the active light reflection sequence
|
|
1067
|
+
* Flashes colors and measures the face bounding box reflection
|
|
1068
|
+
*/
|
|
1069
|
+
async runLivenessSequence(boundingBox, assertFaceClear) {
|
|
1070
|
+
if (!this.videoElement || !this.flashOverlay || !this.ctx) {
|
|
1071
|
+
return { passed: false, score: 0, message: "Engine not fully initialized" };
|
|
1072
|
+
}
|
|
1073
|
+
this.colorHistory = [];
|
|
1074
|
+
const sequence = ["red", "green", "blue", "none"];
|
|
1075
|
+
sequence.sort(() => Math.random() - 0.5);
|
|
1076
|
+
const fullSequence = ["none", ...sequence, "none"];
|
|
1077
|
+
this.canvas.width = this.videoElement.videoWidth;
|
|
1078
|
+
this.canvas.height = this.videoElement.videoHeight;
|
|
1079
|
+
try {
|
|
1080
|
+
for (const color of fullSequence) {
|
|
1081
|
+
if (assertFaceClear) {
|
|
1082
|
+
const status = assertFaceClear();
|
|
1083
|
+
if (!status.aligned) {
|
|
1084
|
+
return { passed: false, score: 0, message: status.message ?? "Face obstruction detected" };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
await this.flashAndMeasure(color, boundingBox);
|
|
1088
|
+
}
|
|
1089
|
+
return this.analyzeResults();
|
|
1090
|
+
} finally {
|
|
1091
|
+
if (this.flashOverlay) {
|
|
1092
|
+
this.flashOverlay.style.backgroundColor = "transparent";
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
async flashAndMeasure(color, box) {
|
|
1097
|
+
if (this.flashOverlay) {
|
|
1098
|
+
switch (color) {
|
|
1099
|
+
case "red":
|
|
1100
|
+
this.flashOverlay.style.backgroundColor = "rgba(255, 0, 0, 0.6)";
|
|
1101
|
+
break;
|
|
1102
|
+
case "green":
|
|
1103
|
+
this.flashOverlay.style.backgroundColor = "rgba(0, 255, 0, 0.6)";
|
|
1104
|
+
break;
|
|
1105
|
+
case "blue":
|
|
1106
|
+
this.flashOverlay.style.backgroundColor = "rgba(0, 0, 255, 0.6)";
|
|
1107
|
+
break;
|
|
1108
|
+
case "none":
|
|
1109
|
+
this.flashOverlay.style.backgroundColor = "transparent";
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
1114
|
+
if (this.videoElement && this.ctx) {
|
|
1115
|
+
this.ctx.drawImage(this.videoElement, 0, 0, this.canvas.width, this.canvas.height);
|
|
1116
|
+
const x = Math.max(0, Math.floor(box.x));
|
|
1117
|
+
const y = Math.max(0, Math.floor(box.y));
|
|
1118
|
+
const w = Math.min(this.canvas.width - x, Math.floor(box.width));
|
|
1119
|
+
const h = Math.min(this.canvas.height - y, Math.floor(box.height));
|
|
1120
|
+
const imageData = this.ctx.getImageData(x, y, w, h);
|
|
1121
|
+
const data = imageData.data;
|
|
1122
|
+
let totalR = 0, totalG = 0, totalB = 0;
|
|
1123
|
+
const pixelCount = w * h;
|
|
1124
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
1125
|
+
totalR += data[i];
|
|
1126
|
+
totalG += data[i + 1];
|
|
1127
|
+
totalB += data[i + 2];
|
|
1128
|
+
}
|
|
1129
|
+
this.colorHistory.push({
|
|
1130
|
+
timestamp: Date.now(),
|
|
1131
|
+
colorFlashed: color,
|
|
1132
|
+
avgR: totalR / pixelCount,
|
|
1133
|
+
avgG: totalG / pixelCount,
|
|
1134
|
+
avgB: totalB / pixelCount
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
analyzeResults() {
|
|
1139
|
+
const baselines = this.colorHistory.filter((h) => h.colorFlashed === "none");
|
|
1140
|
+
if (baselines.length === 0) return { passed: false, score: 0 };
|
|
1141
|
+
const baseR = baselines.reduce((acc, b) => acc + b.avgR, 0) / baselines.length;
|
|
1142
|
+
const baseG = baselines.reduce((acc, b) => acc + b.avgG, 0) / baselines.length;
|
|
1143
|
+
const baseB = baselines.reduce((acc, b) => acc + b.avgB, 0) / baselines.length;
|
|
1144
|
+
let score = 0;
|
|
1145
|
+
let expectedSpikes = 0;
|
|
1146
|
+
for (const record of this.colorHistory) {
|
|
1147
|
+
if (record.colorFlashed === "none") continue;
|
|
1148
|
+
expectedSpikes++;
|
|
1149
|
+
const rDiff = record.avgR - baseR;
|
|
1150
|
+
const gDiff = record.avgG - baseG;
|
|
1151
|
+
const bDiff = record.avgB - baseB;
|
|
1152
|
+
if (record.colorFlashed === "red" && rDiff > 0 && rDiff > Math.max(gDiff, bDiff)) score++;
|
|
1153
|
+
if (record.colorFlashed === "green" && gDiff > 0 && gDiff > Math.max(rDiff, bDiff)) score++;
|
|
1154
|
+
if (record.colorFlashed === "blue" && bDiff > 0 && bDiff > Math.max(rDiff, gDiff)) score++;
|
|
1155
|
+
}
|
|
1156
|
+
const passed = expectedSpikes > 0 && score >= 1;
|
|
1157
|
+
return {
|
|
1158
|
+
passed,
|
|
1159
|
+
score: score / expectedSpikes * 100,
|
|
1160
|
+
message: passed ? "Liveness verified via 3D light reflection" : "Liveness check failed. Ensure adequate lighting, turn up screen brightness, and do not use a photo."
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
// src/core/biometrics/captureQuality.ts
|
|
1166
|
+
var CAPTURE_QUALITY = {
|
|
1167
|
+
/** Min average luminance in face region (0-255). Below = too dark */
|
|
1168
|
+
LUMINANCE_MIN: 80,
|
|
1169
|
+
/** Max average luminance. Above = overexposed / washed out */
|
|
1170
|
+
LUMINANCE_MAX: 220,
|
|
1171
|
+
/** Min face bounding box width/height in pixels */
|
|
1172
|
+
FACE_MIN_SIZE: 100,
|
|
1173
|
+
/** Ideal min size for best results */
|
|
1174
|
+
FACE_IDEAL_SIZE: 140
|
|
1175
|
+
};
|
|
1176
|
+
function getFaceBox(face) {
|
|
1177
|
+
const box = face.box;
|
|
1178
|
+
if (!box) return null;
|
|
1179
|
+
if (Array.isArray(box) && box.length >= 4) {
|
|
1180
|
+
return { x: box[0], y: box[1], width: box[2], height: box[3] };
|
|
1181
|
+
}
|
|
1182
|
+
if (typeof box === "object") {
|
|
1183
|
+
const obj = box;
|
|
1184
|
+
if (obj.width != null && obj.x != null) {
|
|
1185
|
+
return {
|
|
1186
|
+
x: Number(obj.x),
|
|
1187
|
+
y: Number(obj.y ?? 0),
|
|
1188
|
+
width: Number(obj.width),
|
|
1189
|
+
height: Number(obj.height ?? obj.width)
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
if (obj.left != null && obj.right != null) {
|
|
1193
|
+
return {
|
|
1194
|
+
x: Number(obj.left),
|
|
1195
|
+
y: Number(obj.top ?? 0),
|
|
1196
|
+
width: Number(obj.right - obj.left),
|
|
1197
|
+
height: Number((obj.bottom ?? 0) - (obj.top ?? 0))
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return null;
|
|
1202
|
+
}
|
|
1203
|
+
function getRegionLuminance(ctx, x, y, width, height) {
|
|
1204
|
+
const w = Math.max(1, Math.floor(width));
|
|
1205
|
+
const h = Math.max(1, Math.floor(height));
|
|
1206
|
+
const sx = Math.max(0, Math.floor(x));
|
|
1207
|
+
const sy = Math.max(0, Math.floor(y));
|
|
1208
|
+
let sum = 0;
|
|
1209
|
+
let count = 0;
|
|
1210
|
+
const step = 4;
|
|
1211
|
+
try {
|
|
1212
|
+
const imageData = ctx.getImageData(sx, sy, w, h);
|
|
1213
|
+
const data = imageData.data;
|
|
1214
|
+
for (let i = 0; i < data.length; i += step * 4) {
|
|
1215
|
+
const r = data[i];
|
|
1216
|
+
const g = data[i + 1];
|
|
1217
|
+
const b = data[i + 2];
|
|
1218
|
+
sum += 0.299 * r + 0.587 * g + 0.114 * b;
|
|
1219
|
+
count += 1;
|
|
1220
|
+
}
|
|
1221
|
+
} catch {
|
|
1222
|
+
return 0;
|
|
1223
|
+
}
|
|
1224
|
+
return count > 0 ? sum / count : 0;
|
|
1225
|
+
}
|
|
1226
|
+
function getFaceLuminance(ctx, face, canvasWidth, canvasHeight) {
|
|
1227
|
+
const box = getFaceBox(face);
|
|
1228
|
+
if (!box) {
|
|
1229
|
+
return getRegionLuminance(ctx, 0, 0, canvasWidth, canvasHeight);
|
|
1230
|
+
}
|
|
1231
|
+
let x = box.x;
|
|
1232
|
+
let y = box.y;
|
|
1233
|
+
let w = box.width;
|
|
1234
|
+
let h = box.height;
|
|
1235
|
+
if (w <= 1 && h <= 1) {
|
|
1236
|
+
x *= canvasWidth;
|
|
1237
|
+
y *= canvasHeight;
|
|
1238
|
+
w *= canvasWidth;
|
|
1239
|
+
h *= canvasHeight;
|
|
1240
|
+
}
|
|
1241
|
+
x = Math.max(0, Math.min(x, canvasWidth - 1));
|
|
1242
|
+
y = Math.max(0, Math.min(y, canvasHeight - 1));
|
|
1243
|
+
w = Math.max(1, Math.min(w, canvasWidth - x));
|
|
1244
|
+
h = Math.max(1, Math.min(h, canvasHeight - y));
|
|
1245
|
+
return getRegionLuminance(ctx, x, y, w, h);
|
|
1246
|
+
}
|
|
1247
|
+
function evaluateCaptureQuality(luminance, faceWidth, faceHeight) {
|
|
1248
|
+
const luminanceOk = luminance >= CAPTURE_QUALITY.LUMINANCE_MIN && luminance <= CAPTURE_QUALITY.LUMINANCE_MAX;
|
|
1249
|
+
const faceSizeOk = faceWidth >= CAPTURE_QUALITY.FACE_MIN_SIZE && faceHeight >= CAPTURE_QUALITY.FACE_MIN_SIZE;
|
|
1250
|
+
const messages = [];
|
|
1251
|
+
if (!luminanceOk) {
|
|
1252
|
+
if (luminance < CAPTURE_QUALITY.LUMINANCE_MIN) {
|
|
1253
|
+
messages.push("Low light \u2013 move to a brighter area");
|
|
1254
|
+
} else {
|
|
1255
|
+
messages.push("Too bright \u2013 reduce glare or move away from light");
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (!faceSizeOk) {
|
|
1259
|
+
messages.push("Face too small \u2013 move closer to the camera");
|
|
1260
|
+
}
|
|
1261
|
+
if (luminanceOk && faceSizeOk) {
|
|
1262
|
+
messages.push("Good \u2013 enough light and face data");
|
|
1263
|
+
}
|
|
1264
|
+
return {
|
|
1265
|
+
luminance: Math.round(luminance),
|
|
1266
|
+
luminanceOk,
|
|
1267
|
+
faceWidth: Math.round(faceWidth),
|
|
1268
|
+
faceHeight: Math.round(faceHeight),
|
|
1269
|
+
faceSizeOk,
|
|
1270
|
+
ready: luminanceOk && faceSizeOk,
|
|
1271
|
+
message: messages.join(". ")
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// src/core/config/humanConfig.ts
|
|
1276
|
+
var HUMAN_CONFIG = {
|
|
1277
|
+
modelBasePath: "/models/",
|
|
1278
|
+
backend: "webgpu",
|
|
1279
|
+
// WebGPU is fastest, falls back to webgl/wasm
|
|
1280
|
+
async: true,
|
|
1281
|
+
cacheSensitivity: 0,
|
|
1282
|
+
// Disable caching so embeddings are always fresh
|
|
1283
|
+
skipFrame: false,
|
|
1284
|
+
warmup: "none",
|
|
1285
|
+
videoOptimized: true,
|
|
1286
|
+
deallocate: true,
|
|
1287
|
+
face: {
|
|
1288
|
+
enabled: true,
|
|
1289
|
+
detector: {
|
|
1290
|
+
enabled: true,
|
|
1291
|
+
rotation: true,
|
|
1292
|
+
// Max accuracy: detect faces at angles for better embedding alignment
|
|
1293
|
+
return: true,
|
|
1294
|
+
minConfidence: 0.2,
|
|
1295
|
+
maxFaces: 1,
|
|
1296
|
+
modelPath: "blazeface.json"
|
|
1297
|
+
},
|
|
1298
|
+
mesh: {
|
|
1299
|
+
enabled: true,
|
|
1300
|
+
// Must be true for emotion/description models to work
|
|
1301
|
+
return: false
|
|
1302
|
+
// Keep false for performance (we don't need the 468 points back)
|
|
1303
|
+
},
|
|
1304
|
+
landmarks: {
|
|
1305
|
+
enabled: true,
|
|
1306
|
+
return: true
|
|
1307
|
+
},
|
|
1308
|
+
emotion: {
|
|
1309
|
+
enabled: true,
|
|
1310
|
+
return: true,
|
|
1311
|
+
modelPath: "emotion.json",
|
|
1312
|
+
minConfidence: 0.2
|
|
1313
|
+
},
|
|
1314
|
+
description: {
|
|
1315
|
+
enabled: true,
|
|
1316
|
+
return: true,
|
|
1317
|
+
modelPath: "faceres.json"
|
|
1318
|
+
// Use FaceRes (Deep) by replacing faceres.bin with human-models 13.9M variant for max embedding quality
|
|
1319
|
+
},
|
|
1320
|
+
iris: { enabled: false },
|
|
1321
|
+
antispoof: { enabled: true },
|
|
1322
|
+
// Max security: reject photos/screen replay
|
|
1323
|
+
liveness: { enabled: false }
|
|
1324
|
+
// Enable when liveness.json + liveness.bin are in public/models
|
|
1325
|
+
},
|
|
1326
|
+
// Disable unused features
|
|
1327
|
+
filter: {
|
|
1328
|
+
enabled: false
|
|
1329
|
+
// Disable all filters for maximum performance
|
|
1330
|
+
},
|
|
1331
|
+
body: { enabled: false },
|
|
1332
|
+
hand: { enabled: false },
|
|
1333
|
+
object: { enabled: false },
|
|
1334
|
+
gesture: {
|
|
1335
|
+
enabled: false,
|
|
1336
|
+
return: true,
|
|
1337
|
+
minConfidence: 0.2
|
|
1338
|
+
},
|
|
1339
|
+
debug: false,
|
|
1340
|
+
profile: false
|
|
1341
|
+
// Disable profiling in production
|
|
1342
|
+
};
|
|
1343
|
+
var VERIFICATION_CONSTANTS = {
|
|
1344
|
+
HAPPY_SUSTAIN_DURATION: 1500,
|
|
1345
|
+
BLINK_REQUIRED_COUNT: 3,
|
|
1346
|
+
BLINK_COOLDOWN: 300,
|
|
1347
|
+
// Performance constants
|
|
1348
|
+
PROCESS_INTERVAL: 200,
|
|
1349
|
+
// Process every 200ms (5 frames per second is plenty for median averaging)
|
|
1350
|
+
FPS_UPDATE_INTERVAL: 1e3,
|
|
1351
|
+
DEBUG_LOG_INTERVAL: 60,
|
|
1352
|
+
// Resolution: Lowering this provides massive speed boosts without losing embedding quality
|
|
1353
|
+
PROCESS_WIDTH: 640,
|
|
1354
|
+
PROCESS_HEIGHT: 480,
|
|
1355
|
+
// Frame skipping
|
|
1356
|
+
SKIP_FRAMES: 2
|
|
1357
|
+
// Process every 3rd frame
|
|
1358
|
+
};
|
|
1359
|
+
var BACKEND_PRIORITY = ["webgpu", "webgl", "wasm"];
|
|
1360
|
+
|
|
1361
|
+
// src/core/api/relayer.ts
|
|
1362
|
+
var VerifiedRelayerClient = class {
|
|
1363
|
+
constructor(config) {
|
|
1364
|
+
this.baseUrl = config.baseUrl;
|
|
1365
|
+
this.network = config.network || "mainnet";
|
|
1366
|
+
this.appId = config.appId;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Submits the face verification payload to the backend relayer.
|
|
1370
|
+
* The backend handles gas abstraction and execution for both EVM and Solana users.
|
|
1371
|
+
*/
|
|
1372
|
+
async submitVerification(payload) {
|
|
1373
|
+
const { simhashFull, livenessCombined, chain, walletAddress } = payload;
|
|
1374
|
+
const response = await fetch(`${this.baseUrl}/api/arcium/relayer-verify`, {
|
|
1375
|
+
method: "POST",
|
|
1376
|
+
headers: { "Content-Type": "application/json" },
|
|
1377
|
+
body: JSON.stringify({
|
|
1378
|
+
simhashHex: simhashFull,
|
|
1379
|
+
livenessCombined,
|
|
1380
|
+
walletAddress,
|
|
1381
|
+
chain,
|
|
1382
|
+
appId: this.appId
|
|
1383
|
+
})
|
|
1384
|
+
});
|
|
1385
|
+
if (!response.ok) {
|
|
1386
|
+
const errorText = await response.text();
|
|
1387
|
+
throw new Error(`Verification API failed: ${errorText}`);
|
|
1388
|
+
}
|
|
1389
|
+
const data = await response.json();
|
|
1390
|
+
return { success: true, txHash: data.txHash || data.data?.arciumResult?.tx3, data: data.data };
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
// src/react/hooks/useVisionModels.ts
|
|
1395
|
+
import { useState, useEffect } from "react";
|
|
1396
|
+
import { FaceLandmarker, FilesetResolver } from "@mediapipe/tasks-vision";
|
|
1397
|
+
import Human from "@vladmandic/human";
|
|
1398
|
+
var useVisionModels = () => {
|
|
1399
|
+
const [faceLandmarker, setFaceLandmarker] = useState(null);
|
|
1400
|
+
const [human, setHuman] = useState(null);
|
|
1401
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
1402
|
+
const [error, setError] = useState(null);
|
|
1403
|
+
useEffect(() => {
|
|
1404
|
+
let isMounted = true;
|
|
1405
|
+
const initializeModels = async () => {
|
|
1406
|
+
try {
|
|
1407
|
+
console.log("Initializing Vision Models (MediaPipe + ONNX)...");
|
|
1408
|
+
const vision = await FilesetResolver.forVisionTasks(
|
|
1409
|
+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/wasm"
|
|
1410
|
+
);
|
|
1411
|
+
const landmarker = await FaceLandmarker.createFromOptions(vision, {
|
|
1412
|
+
baseOptions: {
|
|
1413
|
+
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
|
|
1414
|
+
},
|
|
1415
|
+
outputFaceBlendshapes: true,
|
|
1416
|
+
outputFacialTransformationMatrixes: true,
|
|
1417
|
+
runningMode: "VIDEO",
|
|
1418
|
+
numFaces: 1
|
|
1419
|
+
});
|
|
1420
|
+
let h = null;
|
|
1421
|
+
try {
|
|
1422
|
+
const config = {
|
|
1423
|
+
...HUMAN_CONFIG,
|
|
1424
|
+
backend: "wasm",
|
|
1425
|
+
// Force WASM to avoid WebGL conflicts with MediaPipe
|
|
1426
|
+
face: {
|
|
1427
|
+
...HUMAN_CONFIG.face,
|
|
1428
|
+
description: { enabled: true, modelPath: "faceres.json" },
|
|
1429
|
+
mesh: { enabled: false },
|
|
1430
|
+
// MediaPipe handles mesh
|
|
1431
|
+
emotion: { enabled: false },
|
|
1432
|
+
antispoof: { enabled: false },
|
|
1433
|
+
detector: { enabled: true, modelPath: "blazeface.json" }
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
h = new Human(config);
|
|
1437
|
+
await h.load();
|
|
1438
|
+
await h.warmup();
|
|
1439
|
+
} catch (humanErr) {
|
|
1440
|
+
console.warn("Human.js models failed to load.", humanErr);
|
|
1441
|
+
}
|
|
1442
|
+
if (isMounted) {
|
|
1443
|
+
setFaceLandmarker(landmarker);
|
|
1444
|
+
setHuman(h);
|
|
1445
|
+
setIsInitialized(true);
|
|
1446
|
+
}
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
console.error("Failed to initialize vision models", err);
|
|
1449
|
+
if (isMounted) {
|
|
1450
|
+
setError(err.message || "Failed to load face detection models");
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
initializeModels();
|
|
1455
|
+
return () => {
|
|
1456
|
+
isMounted = false;
|
|
1457
|
+
};
|
|
1458
|
+
}, []);
|
|
1459
|
+
return { faceLandmarker, human, isInitialized, error };
|
|
1460
|
+
};
|
|
1461
|
+
|
|
1462
|
+
// src/react/hooks/useCameraStream.ts
|
|
1463
|
+
import { useState as useState2, useCallback, useEffect as useEffect2 } from "react";
|
|
1464
|
+
function useCameraStream(isReady) {
|
|
1465
|
+
const [stream, setStream] = useState2(null);
|
|
1466
|
+
const [error, setError] = useState2(null);
|
|
1467
|
+
const startCamera = useCallback(async () => {
|
|
1468
|
+
try {
|
|
1469
|
+
setError(null);
|
|
1470
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
1471
|
+
const videoDevices = devices.filter((d) => d.kind === "videoinput");
|
|
1472
|
+
const frontCamera = videoDevices.find(
|
|
1473
|
+
(d) => d.label.toLowerCase().includes("front") || d.label.toLowerCase().includes("user") || d.label.toLowerCase().includes("facetime")
|
|
1474
|
+
);
|
|
1475
|
+
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
|
1476
|
+
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
1477
|
+
video: {
|
|
1478
|
+
width: isMobile ? { ideal: 640, min: 320 } : { ideal: 1920, min: 1280 },
|
|
1479
|
+
height: isMobile ? { ideal: 480, min: 240 } : { ideal: 1080, min: 720 },
|
|
1480
|
+
facingMode: "user",
|
|
1481
|
+
frameRate: isMobile ? { ideal: 30, min: 15 } : { ideal: 60, min: 30 },
|
|
1482
|
+
aspectRatio: isMobile ? { ideal: 4 / 3 } : { ideal: 16 / 9 },
|
|
1483
|
+
deviceId: frontCamera?.deviceId ? { exact: frontCamera.deviceId } : void 0
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
setStream(mediaStream);
|
|
1487
|
+
console.log("Camera started successfully");
|
|
1488
|
+
} catch (err) {
|
|
1489
|
+
console.error("Camera error:", err);
|
|
1490
|
+
setError("Failed to access camera. Please check permissions.");
|
|
1491
|
+
}
|
|
1492
|
+
}, []);
|
|
1493
|
+
useEffect2(() => {
|
|
1494
|
+
if (isReady && !stream) {
|
|
1495
|
+
startCamera();
|
|
1496
|
+
}
|
|
1497
|
+
}, [isReady, stream, startCamera]);
|
|
1498
|
+
useEffect2(() => {
|
|
1499
|
+
return () => {
|
|
1500
|
+
if (stream) {
|
|
1501
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
}, [stream]);
|
|
1505
|
+
return { stream, error, startCamera };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// src/react/components/FaceZK.tsx
|
|
1509
|
+
import { useRef, useEffect as useEffect3, useState as useState3, useCallback as useCallback2 } from "react";
|
|
1510
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
1511
|
+
var FaceZKInner = ({
|
|
1512
|
+
onBiometricData,
|
|
1513
|
+
onVerificationComplete
|
|
1514
|
+
}) => {
|
|
1515
|
+
const videoRef = useRef(null);
|
|
1516
|
+
const canvasRef = useRef(null);
|
|
1517
|
+
const flashOverlayRef = useRef(null);
|
|
1518
|
+
const animationIdRef = useRef();
|
|
1519
|
+
const { faceLandmarker, human, isInitialized, error: initError } = useVisionModels();
|
|
1520
|
+
const { stream, error: cameraError } = useCameraStream(true);
|
|
1521
|
+
const [livenessEngine] = useState3(() => new LivenessEngine());
|
|
1522
|
+
const [currentStep, setCurrentStep] = useState3("pose-align");
|
|
1523
|
+
const [liveFeedback, setLiveFeedback] = useState3(null);
|
|
1524
|
+
const [isProcessing, setIsProcessing] = useState3(false);
|
|
1525
|
+
const [faceDetected, setFaceDetected] = useState3(false);
|
|
1526
|
+
const [error, setError] = useState3(null);
|
|
1527
|
+
const [poseCheckPassed, setPoseCheckPassed] = useState3(false);
|
|
1528
|
+
const [livenessPassed, setLivenessPassed] = useState3(false);
|
|
1529
|
+
useEffect3(() => {
|
|
1530
|
+
setError(initError || cameraError);
|
|
1531
|
+
}, [initError, cameraError]);
|
|
1532
|
+
useEffect3(() => {
|
|
1533
|
+
if (!stream || !videoRef.current) return;
|
|
1534
|
+
videoRef.current.srcObject = stream;
|
|
1535
|
+
const initEngine = () => {
|
|
1536
|
+
videoRef.current?.play().catch((e) => console.warn("Auto-play prevented", e));
|
|
1537
|
+
if (videoRef.current && flashOverlayRef.current) {
|
|
1538
|
+
console.log("Initializing Liveness Engine with video elements");
|
|
1539
|
+
livenessEngine.setVideoElement(videoRef.current);
|
|
1540
|
+
livenessEngine.setFlashOverlay(flashOverlayRef.current);
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
if (videoRef.current.readyState >= 1) {
|
|
1544
|
+
initEngine();
|
|
1545
|
+
} else {
|
|
1546
|
+
videoRef.current.onloadedmetadata = initEngine;
|
|
1547
|
+
}
|
|
1548
|
+
}, [stream, livenessEngine]);
|
|
1549
|
+
const extractFaceEmbedding = async (video) => {
|
|
1550
|
+
if (!human) {
|
|
1551
|
+
throw new Error("Face recognition model not loaded. Please wait and retry.");
|
|
1552
|
+
}
|
|
1553
|
+
try {
|
|
1554
|
+
const result = await human.detect(video);
|
|
1555
|
+
if (result && result.face && result.face.length > 0 && result.face[0].embedding) {
|
|
1556
|
+
return Array.from(result.face[0].embedding);
|
|
1557
|
+
}
|
|
1558
|
+
throw new Error("No face embedding extracted from frame");
|
|
1559
|
+
} catch (e) {
|
|
1560
|
+
console.error("Human.js Inference failed", e);
|
|
1561
|
+
throw e;
|
|
1562
|
+
}
|
|
1563
|
+
};
|
|
1564
|
+
const cosineSimilarity = (a, b) => {
|
|
1565
|
+
let dot = 0, normA = 0, normB = 0;
|
|
1566
|
+
for (let i = 0; i < a.length; i++) {
|
|
1567
|
+
dot += a[i] * b[i];
|
|
1568
|
+
normA += a[i] * a[i];
|
|
1569
|
+
normB += b[i] * b[i];
|
|
1570
|
+
}
|
|
1571
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
1572
|
+
};
|
|
1573
|
+
const captureMultipleEmbeddings = async (video, sampleCount = 10, intervalMs = 200) => {
|
|
1574
|
+
const embeddings = [];
|
|
1575
|
+
console.log(`\u{1F4F8} Starting multi-frame capture: ${sampleCount} samples, ${intervalMs}ms apart`);
|
|
1576
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
1577
|
+
try {
|
|
1578
|
+
const emb = await extractFaceEmbedding(video);
|
|
1579
|
+
embeddings.push(emb);
|
|
1580
|
+
const norm = Math.sqrt(emb.reduce((s, v) => s + v * v, 0));
|
|
1581
|
+
console.log(` Frame ${i + 1}/${sampleCount}: L2 norm = ${norm.toFixed(4)}`);
|
|
1582
|
+
if (embeddings.length > 1) {
|
|
1583
|
+
const prev = embeddings[embeddings.length - 2];
|
|
1584
|
+
const sim = cosineSimilarity(prev, emb);
|
|
1585
|
+
console.log(` Cosine similarity (frame ${i} vs ${i + 1}): ${sim.toFixed(6)}`);
|
|
1586
|
+
}
|
|
1587
|
+
setLiveFeedback({ message: `Capturing biometric data... (${i + 1}/${sampleCount})`, isGood: true });
|
|
1588
|
+
} catch (e) {
|
|
1589
|
+
console.warn(` Frame ${i + 1} capture failed, skipping:`, e);
|
|
1590
|
+
}
|
|
1591
|
+
if (i < sampleCount - 1) {
|
|
1592
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (embeddings.length < 3) {
|
|
1596
|
+
throw new Error(`Only captured ${embeddings.length} valid frames. Need at least 3.`);
|
|
1597
|
+
}
|
|
1598
|
+
const averaged = averageEmbeddings(embeddings);
|
|
1599
|
+
const avgNorm = Math.sqrt(averaged.reduce((s, v) => s + v * v, 0));
|
|
1600
|
+
console.log(`\u2705 Robust average complete. L2 norm = ${avgNorm.toFixed(4)}`);
|
|
1601
|
+
return averaged;
|
|
1602
|
+
};
|
|
1603
|
+
const latestLandmarks = useRef(null);
|
|
1604
|
+
const latestMatrix = useRef(null);
|
|
1605
|
+
const renderLoop = useCallback2(() => {
|
|
1606
|
+
if (!videoRef.current || !faceLandmarker || !isInitialized) return;
|
|
1607
|
+
if (videoRef.current.videoWidth === 0 || videoRef.current.videoHeight === 0) {
|
|
1608
|
+
if (currentStep !== "completed") {
|
|
1609
|
+
animationIdRef.current = requestAnimationFrame(renderLoop);
|
|
1610
|
+
}
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
try {
|
|
1614
|
+
const startTimeMs = performance.now();
|
|
1615
|
+
if (startTimeMs > renderLoop.lastTime) {
|
|
1616
|
+
const results = faceLandmarker.detectForVideo(videoRef.current, startTimeMs);
|
|
1617
|
+
renderLoop.lastTime = startTimeMs;
|
|
1618
|
+
const facePresent = results.faceLandmarks && results.faceLandmarks.length > 0;
|
|
1619
|
+
setFaceDetected((prev) => prev !== facePresent ? facePresent : prev);
|
|
1620
|
+
if (facePresent) {
|
|
1621
|
+
latestLandmarks.current = results.faceLandmarks[0];
|
|
1622
|
+
if (results.facialTransformationMatrixes && results.facialTransformationMatrixes.length > 0) {
|
|
1623
|
+
latestMatrix.current = results.facialTransformationMatrixes[0].data;
|
|
1624
|
+
}
|
|
1625
|
+
if (canvasRef.current) {
|
|
1626
|
+
const canvas = canvasRef.current;
|
|
1627
|
+
const ctx = canvas.getContext("2d");
|
|
1628
|
+
if (ctx) {
|
|
1629
|
+
canvas.width = videoRef.current.videoWidth;
|
|
1630
|
+
canvas.height = videoRef.current.videoHeight;
|
|
1631
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1632
|
+
ctx.fillStyle = "#00FF88";
|
|
1633
|
+
for (const point of latestLandmarks.current) {
|
|
1634
|
+
ctx.fillRect(point.x * canvas.width, point.y * canvas.height, 2, 2);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
} else {
|
|
1639
|
+
latestLandmarks.current = null;
|
|
1640
|
+
latestMatrix.current = null;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
} catch (e) {
|
|
1644
|
+
console.error("Detection error:", e);
|
|
1645
|
+
}
|
|
1646
|
+
if (currentStep !== "completed") {
|
|
1647
|
+
animationIdRef.current = requestAnimationFrame(renderLoop);
|
|
1648
|
+
}
|
|
1649
|
+
}, [faceLandmarker, isInitialized, currentStep]);
|
|
1650
|
+
renderLoop.lastTime = 0;
|
|
1651
|
+
useEffect3(() => {
|
|
1652
|
+
if (isInitialized && stream) {
|
|
1653
|
+
renderLoop();
|
|
1654
|
+
}
|
|
1655
|
+
return () => {
|
|
1656
|
+
if (animationIdRef.current) cancelAnimationFrame(animationIdRef.current);
|
|
1657
|
+
};
|
|
1658
|
+
}, [isInitialized, stream, renderLoop]);
|
|
1659
|
+
useEffect3(() => {
|
|
1660
|
+
const runVerification = async () => {
|
|
1661
|
+
if (!faceDetected || isProcessing || !latestLandmarks.current) return;
|
|
1662
|
+
setIsProcessing(true);
|
|
1663
|
+
setError(null);
|
|
1664
|
+
const getFaceBoundingBox = () => {
|
|
1665
|
+
if (!latestLandmarks.current || !videoRef.current) return null;
|
|
1666
|
+
const video = videoRef.current;
|
|
1667
|
+
const xs = latestLandmarks.current.map((l) => l.x * video.videoWidth);
|
|
1668
|
+
const ys = latestLandmarks.current.map((l) => l.y * video.videoHeight);
|
|
1669
|
+
return {
|
|
1670
|
+
x: Math.min(...xs),
|
|
1671
|
+
y: Math.min(...ys),
|
|
1672
|
+
width: Math.max(...xs) - Math.min(...xs),
|
|
1673
|
+
height: Math.max(...ys) - Math.min(...ys)
|
|
1674
|
+
};
|
|
1675
|
+
};
|
|
1676
|
+
const checkFaceAlignment = () => {
|
|
1677
|
+
const bbox = getFaceBoundingBox();
|
|
1678
|
+
const video = videoRef.current;
|
|
1679
|
+
if (!bbox || !video || !latestLandmarks.current) return { aligned: false, message: "Face not detected." };
|
|
1680
|
+
const faceHeightRatio = bbox.height / video.videoHeight;
|
|
1681
|
+
if (faceHeightRatio < 0.3) return { aligned: false, message: "Face is too far away. Move closer." };
|
|
1682
|
+
if (faceHeightRatio > 0.9) return { aligned: false, message: "Face is too close. Move back." };
|
|
1683
|
+
const faceWidthRatio = bbox.width / video.videoWidth;
|
|
1684
|
+
if (faceWidthRatio < 0.2) return { aligned: false, message: "Face is too far away. Move closer." };
|
|
1685
|
+
const centerX = bbox.x + bbox.width / 2;
|
|
1686
|
+
const centerY = bbox.y + bbox.height / 2;
|
|
1687
|
+
const xOffset = Math.abs(centerX - video.videoWidth / 2) / video.videoWidth;
|
|
1688
|
+
const yOffset = Math.abs(centerY - video.videoHeight / 2) / video.videoHeight;
|
|
1689
|
+
if (xOffset > 0.25 || yOffset > 0.25) return { aligned: false, message: "Align your face in the center of the frame." };
|
|
1690
|
+
try {
|
|
1691
|
+
const offscreenCanvas = document.createElement("canvas");
|
|
1692
|
+
offscreenCanvas.width = video.videoWidth;
|
|
1693
|
+
offscreenCanvas.height = video.videoHeight;
|
|
1694
|
+
const ctx = offscreenCanvas.getContext("2d", { willReadFrequently: true });
|
|
1695
|
+
if (ctx) {
|
|
1696
|
+
ctx.drawImage(video, 0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
|
1697
|
+
const getRGB = (index) => {
|
|
1698
|
+
const pt = latestLandmarks.current[index];
|
|
1699
|
+
if (!pt) return { r: 0, g: 0, b: 0, luma: 0 };
|
|
1700
|
+
const x = Math.max(0, Math.min(offscreenCanvas.width - 1, Math.floor(pt.x * offscreenCanvas.width)));
|
|
1701
|
+
const y = Math.max(0, Math.min(offscreenCanvas.height - 1, Math.floor(pt.y * offscreenCanvas.height)));
|
|
1702
|
+
const startX = Math.max(0, x - 2);
|
|
1703
|
+
const startY = Math.max(0, y - 2);
|
|
1704
|
+
const imgData = ctx.getImageData(startX, startY, 5, 5).data;
|
|
1705
|
+
let r = 0, g = 0, b = 0;
|
|
1706
|
+
for (let i = 0; i < imgData.length; i += 4) {
|
|
1707
|
+
r += imgData[i];
|
|
1708
|
+
g += imgData[i + 1];
|
|
1709
|
+
b += imgData[i + 2];
|
|
1710
|
+
}
|
|
1711
|
+
const count = imgData.length / 4;
|
|
1712
|
+
return {
|
|
1713
|
+
r: r / count,
|
|
1714
|
+
g: g / count,
|
|
1715
|
+
b: b / count,
|
|
1716
|
+
luma: 0.299 * (r / count) + 0.587 * (g / count) + 0.114 * (b / count)
|
|
1717
|
+
};
|
|
1718
|
+
};
|
|
1719
|
+
const leftCheek = getRGB(50);
|
|
1720
|
+
const rightCheek = getRGB(280);
|
|
1721
|
+
const forehead = getRGB(151);
|
|
1722
|
+
const leftEye = getRGB(145);
|
|
1723
|
+
const rightEye = getRGB(374);
|
|
1724
|
+
const noseTip = getRGB(1);
|
|
1725
|
+
const chin = getRGB(152);
|
|
1726
|
+
const avgSkinLuma = (leftCheek.luma + rightCheek.luma + forehead.luma) / 3;
|
|
1727
|
+
if (leftEye.luma < 25 && rightEye.luma < 25 && avgSkinLuma > 50) {
|
|
1728
|
+
if (leftEye.luma < avgSkinLuma * 0.3 && rightEye.luma < avgSkinLuma * 0.3) {
|
|
1729
|
+
return { aligned: false, message: "Please remove your dark shades." };
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
const colorDist = (c1, c2) => Math.sqrt(Math.pow(c1.r - c2.r, 2) + Math.pow(c1.g - c2.g, 2) + Math.pow(c1.b - c2.b, 2));
|
|
1733
|
+
const avgCheek = { r: (leftCheek.r + rightCheek.r) / 2, g: (leftCheek.g + rightCheek.g) / 2, b: (leftCheek.b + rightCheek.b) / 2 };
|
|
1734
|
+
if (colorDist(avgCheek, noseTip) > 130 && colorDist(avgCheek, chin) > 130) {
|
|
1735
|
+
return { aligned: false, message: "Please remove your face mask." };
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
} catch (e) {
|
|
1739
|
+
console.warn("Occlusion detection skipped due to canvas error", e);
|
|
1740
|
+
}
|
|
1741
|
+
return { aligned: true, message: "Perfect! Hold still." };
|
|
1742
|
+
};
|
|
1743
|
+
const waitForAlignment = async () => {
|
|
1744
|
+
let consecutiveGood = 0;
|
|
1745
|
+
while (true) {
|
|
1746
|
+
const status = checkFaceAlignment();
|
|
1747
|
+
if (status.aligned) {
|
|
1748
|
+
setLiveFeedback((prev) => prev?.message === status.message && prev?.isGood === true ? prev : { message: status.message, isGood: true });
|
|
1749
|
+
consecutiveGood++;
|
|
1750
|
+
if (consecutiveGood > 10) {
|
|
1751
|
+
setLiveFeedback(null);
|
|
1752
|
+
return true;
|
|
1753
|
+
}
|
|
1754
|
+
} else {
|
|
1755
|
+
setLiveFeedback((prev) => prev?.message === status.message && prev?.isGood === false ? prev : { message: status.message, isGood: false });
|
|
1756
|
+
consecutiveGood = 0;
|
|
1757
|
+
}
|
|
1758
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
const waitForQuality = async () => {
|
|
1762
|
+
const oc = document.createElement("canvas");
|
|
1763
|
+
let ctx = null;
|
|
1764
|
+
while (true) {
|
|
1765
|
+
if (!videoRef.current || !latestLandmarks.current) {
|
|
1766
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1767
|
+
continue;
|
|
1768
|
+
}
|
|
1769
|
+
const video = videoRef.current;
|
|
1770
|
+
const bbox = getFaceBoundingBox();
|
|
1771
|
+
if (oc.width !== video.videoWidth) {
|
|
1772
|
+
oc.width = video.videoWidth;
|
|
1773
|
+
oc.height = video.videoHeight;
|
|
1774
|
+
ctx = oc.getContext("2d", { willReadFrequently: true });
|
|
1775
|
+
}
|
|
1776
|
+
let luminance = 128;
|
|
1777
|
+
if (ctx) {
|
|
1778
|
+
try {
|
|
1779
|
+
ctx.drawImage(video, 0, 0);
|
|
1780
|
+
if (bbox) {
|
|
1781
|
+
luminance = getRegionLuminance(ctx, bbox.x, bbox.y, bbox.width, bbox.height);
|
|
1782
|
+
} else {
|
|
1783
|
+
luminance = getRegionLuminance(ctx, 0, 0, oc.width, oc.height);
|
|
1784
|
+
}
|
|
1785
|
+
} catch {
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
const faceW = bbox?.width ?? 0;
|
|
1789
|
+
const faceH = bbox?.height ?? 0;
|
|
1790
|
+
const quality = evaluateCaptureQuality(luminance, faceW, faceH);
|
|
1791
|
+
if (quality.ready) {
|
|
1792
|
+
setLiveFeedback(null);
|
|
1793
|
+
return true;
|
|
1794
|
+
}
|
|
1795
|
+
setLiveFeedback({ message: quality.message, isGood: false });
|
|
1796
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
const getYawDegrees = (matrix) => {
|
|
1800
|
+
return -Math.atan2(matrix[8], matrix[10]) * (180 / Math.PI);
|
|
1801
|
+
};
|
|
1802
|
+
const waitForPose = async (condition) => {
|
|
1803
|
+
while (true) {
|
|
1804
|
+
if (!latestMatrix.current) {
|
|
1805
|
+
setLiveFeedback((prev) => prev?.message === "Face not detected. Keep your face in frame." && prev?.isGood === false ? prev : { message: "Face not detected. Keep your face in frame.", isGood: false });
|
|
1806
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
const status = checkFaceAlignment();
|
|
1810
|
+
if (!status.aligned) {
|
|
1811
|
+
setLiveFeedback((prev) => prev?.message === status.message && prev?.isGood === false ? prev : { message: status.message, isGood: false });
|
|
1812
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
setLiveFeedback(null);
|
|
1816
|
+
const yaw = getYawDegrees(latestMatrix.current);
|
|
1817
|
+
if (condition(yaw)) {
|
|
1818
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1819
|
+
if (latestMatrix.current && condition(getYawDegrees(latestMatrix.current))) {
|
|
1820
|
+
return true;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1826
|
+
const handleFailure = (msg) => {
|
|
1827
|
+
console.warn("Verification sequence reset:", msg);
|
|
1828
|
+
setLiveFeedback((prev) => prev?.message === msg && prev?.isGood === false ? prev : { message: msg, isGood: false });
|
|
1829
|
+
setCurrentStep("pose-align");
|
|
1830
|
+
setError(null);
|
|
1831
|
+
setTimeout(() => {
|
|
1832
|
+
setIsProcessing(false);
|
|
1833
|
+
setLiveFeedback(null);
|
|
1834
|
+
}, 4e3);
|
|
1835
|
+
};
|
|
1836
|
+
try {
|
|
1837
|
+
setCurrentStep("pose-align");
|
|
1838
|
+
await waitForAlignment();
|
|
1839
|
+
await waitForQuality();
|
|
1840
|
+
setCurrentStep("pose-left");
|
|
1841
|
+
await waitForPose((yaw) => yaw > 15);
|
|
1842
|
+
setCurrentStep("pose-right");
|
|
1843
|
+
await waitForPose((yaw) => Math.abs(yaw) < -15 || yaw < -15);
|
|
1844
|
+
setCurrentStep("pose-center");
|
|
1845
|
+
await waitForPose((yaw) => Math.abs(yaw) < 8);
|
|
1846
|
+
setCurrentStep("capturing");
|
|
1847
|
+
const video = videoRef.current;
|
|
1848
|
+
let embedding;
|
|
1849
|
+
try {
|
|
1850
|
+
embedding = await captureMultipleEmbeddings(video, 10, 200);
|
|
1851
|
+
} catch (e) {
|
|
1852
|
+
handleFailure(e.message || "Failed to capture face data");
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
const bbox = getFaceBoundingBox();
|
|
1856
|
+
if (!bbox) {
|
|
1857
|
+
handleFailure("Face lost during final check");
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
const alignment = checkFaceAlignment();
|
|
1861
|
+
if (!alignment.aligned) {
|
|
1862
|
+
handleFailure(`Alignment lost: ${alignment.message}`);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
setCurrentStep("light-flash");
|
|
1866
|
+
const livenessResult = await livenessEngine.runLivenessSequence(bbox);
|
|
1867
|
+
if (!livenessResult.passed) {
|
|
1868
|
+
handleFailure(livenessResult.message || "Liveness check failed");
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
setCurrentStep("processing");
|
|
1872
|
+
const mockFaceData = {
|
|
1873
|
+
embedding,
|
|
1874
|
+
box: [bbox.x, bbox.y, bbox.width, bbox.height],
|
|
1875
|
+
rotation: { angle: { yaw: 0, pitch: 0, roll: 0 } },
|
|
1876
|
+
mesh: latestLandmarks.current.map((l) => [l.x, l.y, l.z])
|
|
1877
|
+
};
|
|
1878
|
+
const mockLivenessData = {
|
|
1879
|
+
blinkCount: 1,
|
|
1880
|
+
blinkTimestamps: [Date.now()],
|
|
1881
|
+
emotionTransitions: [{ from: "neutral", to: "happy", timestamp: Date.now(), duration: 500 }],
|
|
1882
|
+
headMovement: { yawRange: [0, 0], pitchRange: [0, 0], rollRange: [0, 0] },
|
|
1883
|
+
verificationDuration: 3e3,
|
|
1884
|
+
stepCompletionTimes: { happy: 1e3, eyeBlink: 2e3 }
|
|
1885
|
+
};
|
|
1886
|
+
const extractedRawData = {
|
|
1887
|
+
faceEmbedding: embedding,
|
|
1888
|
+
faceMesh: latestLandmarks.current.map((l) => [l.x, l.y, l.z]),
|
|
1889
|
+
faceLandmarks: latestLandmarks.current.map((l) => [l.x, l.y]),
|
|
1890
|
+
faceRotation: { angle: { yaw: 0, pitch: 0, roll: 0 }, matrix: [] },
|
|
1891
|
+
faceBox: bbox,
|
|
1892
|
+
timestamp: Date.now()
|
|
1893
|
+
};
|
|
1894
|
+
const payload = generateBiometricSimHash(mockFaceData, mockLivenessData, embedding);
|
|
1895
|
+
if (onBiometricData) onBiometricData(payload);
|
|
1896
|
+
if (onVerificationComplete) onVerificationComplete({
|
|
1897
|
+
rawFacialData: extractedRawData,
|
|
1898
|
+
livenessData: mockLivenessData,
|
|
1899
|
+
isComplete: true,
|
|
1900
|
+
message: "Verification completed successfully"
|
|
1901
|
+
});
|
|
1902
|
+
setCurrentStep("completed");
|
|
1903
|
+
} catch (err) {
|
|
1904
|
+
console.error("Unexpected verification error:", err);
|
|
1905
|
+
handleFailure(err.message || "An unexpected error occurred");
|
|
1906
|
+
}
|
|
1907
|
+
};
|
|
1908
|
+
runVerification();
|
|
1909
|
+
}, [faceDetected, isProcessing]);
|
|
1910
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
1911
|
+
/* @__PURE__ */ jsxs("div", { className: "relative overflow-hidden rounded-xl", style: { maxHeight: "600px" }, children: [
|
|
1912
|
+
/* @__PURE__ */ jsx(
|
|
1913
|
+
"video",
|
|
1914
|
+
{
|
|
1915
|
+
ref: videoRef,
|
|
1916
|
+
className: "w-full h-full object-contain sm:object-cover",
|
|
1917
|
+
autoPlay: true,
|
|
1918
|
+
playsInline: true,
|
|
1919
|
+
muted: true,
|
|
1920
|
+
style: { transform: "scaleX(-1)" }
|
|
1921
|
+
}
|
|
1922
|
+
),
|
|
1923
|
+
/* @__PURE__ */ jsx(
|
|
1924
|
+
"canvas",
|
|
1925
|
+
{
|
|
1926
|
+
ref: canvasRef,
|
|
1927
|
+
className: "absolute inset-0 w-full h-full pointer-events-none",
|
|
1928
|
+
style: { zIndex: 10, transform: "scaleX(-1)" }
|
|
1929
|
+
}
|
|
1930
|
+
),
|
|
1931
|
+
/* @__PURE__ */ jsx(
|
|
1932
|
+
"div",
|
|
1933
|
+
{
|
|
1934
|
+
ref: flashOverlayRef,
|
|
1935
|
+
className: "absolute inset-0 w-full h-full pointer-events-none transition-colors duration-75",
|
|
1936
|
+
style: { zIndex: 20 }
|
|
1937
|
+
}
|
|
1938
|
+
),
|
|
1939
|
+
/* @__PURE__ */ jsx("div", { className: "absolute top-4 left-4 z-30", children: faceDetected ? /* @__PURE__ */ jsxs("div", { className: "bg-green-500/90 backdrop-blur-sm text-white px-3 py-1 rounded-full text-sm font-bold shadow-[0_0_10px_rgba(34,197,94,0.5)] border border-green-400 flex items-center gap-2", children: [
|
|
1940
|
+
/* @__PURE__ */ jsx("div", { className: "w-2 h-2 bg-white rounded-full animate-pulse" }),
|
|
1941
|
+
"Face Detected"
|
|
1942
|
+
] }) : /* @__PURE__ */ jsxs("div", { className: "bg-red-500/90 backdrop-blur-sm text-white px-3 py-1 rounded-full text-sm font-bold shadow-[0_0_10px_rgba(239,68,68,0.5)] border border-red-400 flex items-center gap-2", children: [
|
|
1943
|
+
/* @__PURE__ */ jsx("div", { className: "w-2 h-2 bg-white rounded-full" }),
|
|
1944
|
+
"No Face Detected"
|
|
1945
|
+
] }) })
|
|
1946
|
+
] }),
|
|
1947
|
+
/* @__PURE__ */ jsxs("div", { className: "text-center mb-4 pt-4 px-4 h-[100px]", children: [
|
|
1948
|
+
!stream && /* @__PURE__ */ jsx("div", { className: "text-gray-500", children: "Initializing camera..." }),
|
|
1949
|
+
liveFeedback ? /* @__PURE__ */ jsx("div", { className: `text-lg font-bold animate-pulse ${liveFeedback.isGood ? "text-green-400" : "text-amber-400"}`, children: liveFeedback.message }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1950
|
+
stream && currentStep === "pose-align" && /* @__PURE__ */ jsx("div", { className: "text-blue-400 text-lg font-bold animate-pulse", children: "Position face in center & ensure good distance" }),
|
|
1951
|
+
stream && currentStep === "pose-left" && /* @__PURE__ */ jsx("div", { className: "text-white text-lg font-bold animate-pulse", children: "Turn your head slowly to the LEFT" }),
|
|
1952
|
+
stream && currentStep === "pose-right" && /* @__PURE__ */ jsx("div", { className: "text-white text-lg font-bold animate-pulse", children: "Turn your head slowly to the RIGHT" }),
|
|
1953
|
+
stream && currentStep === "pose-center" && /* @__PURE__ */ jsx("div", { className: "text-white text-lg font-bold", children: "Look straight at the camera" }),
|
|
1954
|
+
stream && currentStep === "capturing" && /* @__PURE__ */ jsx("div", { className: "text-cyan-400 text-lg font-bold animate-pulse", children: "Capturing biometric data... Hold still" }),
|
|
1955
|
+
stream && currentStep === "light-flash" && /* @__PURE__ */ jsx("div", { className: "text-blue-400 font-bold", children: "Verifying physical presence (Flashing colors...)" }),
|
|
1956
|
+
stream && currentStep === "processing" && /* @__PURE__ */ jsx("div", { className: "text-white", children: "Generating Cryptographic Embedding..." }),
|
|
1957
|
+
stream && currentStep === "completed" && /* @__PURE__ */ jsx("div", { className: "text-green-500 font-bold", children: "Verification Successful" })
|
|
1958
|
+
] })
|
|
1959
|
+
] })
|
|
1960
|
+
] });
|
|
1961
|
+
};
|
|
1962
|
+
var FaceZK_default = FaceZKInner;
|
|
1963
|
+
|
|
1964
|
+
// src/react/components/ProofOfHumanity.tsx
|
|
1965
|
+
import { useState as useState4 } from "react";
|
|
1966
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1967
|
+
var ProofOfHumanity = ({
|
|
1968
|
+
onNextStep,
|
|
1969
|
+
onBiometricData,
|
|
1970
|
+
onVerificationComplete,
|
|
1971
|
+
className = "",
|
|
1972
|
+
containerClassName = "",
|
|
1973
|
+
buttonClassName = ""
|
|
1974
|
+
}) => {
|
|
1975
|
+
const [cameraActive, setCameraActive] = useState4(false);
|
|
1976
|
+
const [verificationComplete, setVerificationComplete] = useState4(false);
|
|
1977
|
+
const [showComparison, setShowComparison] = useState4(false);
|
|
1978
|
+
const handleActivateCamera = () => {
|
|
1979
|
+
setCameraActive(true);
|
|
1980
|
+
};
|
|
1981
|
+
const handleBiometricData = (data) => {
|
|
1982
|
+
console.log("\u{1F3AF} Face verification SimHash received:", data);
|
|
1983
|
+
if (onBiometricData) {
|
|
1984
|
+
onBiometricData(data);
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
const handleVerificationComplete = (result) => {
|
|
1988
|
+
console.log("Verification complete:", result);
|
|
1989
|
+
setVerificationComplete(true);
|
|
1990
|
+
if (onVerificationComplete) {
|
|
1991
|
+
onVerificationComplete(result);
|
|
1992
|
+
}
|
|
1993
|
+
};
|
|
1994
|
+
const handleContinue = () => {
|
|
1995
|
+
console.log("Continuing to next step...");
|
|
1996
|
+
onNextStep();
|
|
1997
|
+
};
|
|
1998
|
+
const toggleComparison = () => {
|
|
1999
|
+
setShowComparison(!showComparison);
|
|
2000
|
+
};
|
|
2001
|
+
return /* @__PURE__ */ jsxs2("div", { className: `mx-auto mt-5 w-full max-w-[700px] flex flex-col items-center px-4 ${className}`, children: [
|
|
2002
|
+
/* @__PURE__ */ jsxs2("div", { className: "flex flex-col items-center mb-6 sm:mb-10", children: [
|
|
2003
|
+
/* @__PURE__ */ jsx2("h2", { className: "text-white text-xl sm:text-2xl md:text-3xl font-bold mb-2 text-center", children: "Proof of Humanity" }),
|
|
2004
|
+
/* @__PURE__ */ jsxs2("p", { className: "text-[#A0A3BD] text-center w-full max-w-[620px] text-sm sm:text-base px-2", children: [
|
|
2005
|
+
"Powered by advanced face detection technology with sequential liveness detection. Your facial data is collected and processed securely. ",
|
|
2006
|
+
/* @__PURE__ */ jsx2("span", { className: "text-white cursor-pointer", children: "Learn more." }),
|
|
2007
|
+
/* @__PURE__ */ jsx2("span", { className: "text-amber-400 block mt-2 font-medium", children: "Please remove any glasses and face masks before verifying." })
|
|
2008
|
+
] })
|
|
2009
|
+
] }),
|
|
2010
|
+
/* @__PURE__ */ jsx2("div", { className: `backdrop-container flex flex-col items-center justify-center bg-[#030B29] rounded-xl w-full max-w-[700px] h-[400px] sm:h-[450px] md:h-[477px] border border-gray-800 ${containerClassName}`, children: !cameraActive ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
2011
|
+
/* @__PURE__ */ jsx2(
|
|
2012
|
+
"img",
|
|
2013
|
+
{
|
|
2014
|
+
src: "/camera.png",
|
|
2015
|
+
alt: "Camera Icon",
|
|
2016
|
+
width: 200,
|
|
2017
|
+
height: 200,
|
|
2018
|
+
className: "sm:w-[250px] sm:h-[250px] md:w-[280px] md:h-[280px]"
|
|
2019
|
+
}
|
|
2020
|
+
),
|
|
2021
|
+
/* @__PURE__ */ jsx2(
|
|
2022
|
+
"button",
|
|
2023
|
+
{
|
|
2024
|
+
className: `bg-[#0D3DDE] hover:bg-[#1A2240] text-white font-semibold py-2 px-6 rounded-lg transition-colors duration-200 mt-4 sm:mt-8 cursor-pointer text-sm sm:text-base ${buttonClassName}`,
|
|
2025
|
+
onClick: handleActivateCamera,
|
|
2026
|
+
children: "Activate Camera"
|
|
2027
|
+
}
|
|
2028
|
+
)
|
|
2029
|
+
] }) : /* @__PURE__ */ jsx2("div", { className: "w-full h-full", children: /* @__PURE__ */ jsx2(
|
|
2030
|
+
FaceZK_default,
|
|
2031
|
+
{
|
|
2032
|
+
onBiometricData: handleBiometricData,
|
|
2033
|
+
onVerificationComplete: handleVerificationComplete,
|
|
2034
|
+
onContinue: handleContinue
|
|
2035
|
+
}
|
|
2036
|
+
) }) })
|
|
2037
|
+
] });
|
|
2038
|
+
};
|
|
2039
|
+
var ProofOfHumanity_default = ProofOfHumanity;
|
|
2040
|
+
|
|
2041
|
+
// src/react/components/VerifiedOnchainFlow.tsx
|
|
2042
|
+
import { useState as useState7, useEffect as useEffect5 } from "react";
|
|
2043
|
+
import { useAccount } from "wagmi";
|
|
2044
|
+
import { useWallet as useSolanaWallet } from "@solana/wallet-adapter-react";
|
|
2045
|
+
|
|
2046
|
+
// src/react/components/wallet/WalletSelection.tsx
|
|
2047
|
+
import { useState as useState5 } from "react";
|
|
2048
|
+
import { useConnect } from "wagmi";
|
|
2049
|
+
import { useWallet } from "@solana/wallet-adapter-react";
|
|
2050
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
2051
|
+
var EVM_WALLET_ICONS = {
|
|
2052
|
+
rainbow: "/rainbow.svg",
|
|
2053
|
+
metamask: "/MetaMask.svg"
|
|
2054
|
+
};
|
|
2055
|
+
var WalletSelection = ({ onWalletConnect, className = "", requiredWallets }) => {
|
|
2056
|
+
const { connectors, connect } = useConnect();
|
|
2057
|
+
const { wallets, select, wallet, disconnect, connected, connect: connectSolana, connecting } = useWallet();
|
|
2058
|
+
const [isBusy, setIsBusy] = useState5(false);
|
|
2059
|
+
const allWallets = [
|
|
2060
|
+
...wallets.map((w) => {
|
|
2061
|
+
let icon = w.adapter.icon;
|
|
2062
|
+
let label = w.adapter.name;
|
|
2063
|
+
let connectSlug = w.adapter.name.toLowerCase().replace(/\s+/g, "-");
|
|
2064
|
+
if (label.toLowerCase().includes("phantom")) {
|
|
2065
|
+
label = "Phantom ";
|
|
2066
|
+
connectSlug = "solana-phantom";
|
|
2067
|
+
} else if (label.toLowerCase().includes("backpack")) {
|
|
2068
|
+
label = "Backpack ";
|
|
2069
|
+
connectSlug = "solana-backpack";
|
|
2070
|
+
}
|
|
2071
|
+
return {
|
|
2072
|
+
id: w.adapter.name,
|
|
2073
|
+
adapterName: w.adapter.name,
|
|
2074
|
+
name: label,
|
|
2075
|
+
connectSlug,
|
|
2076
|
+
icon,
|
|
2077
|
+
type: "solana",
|
|
2078
|
+
original: w
|
|
2079
|
+
};
|
|
2080
|
+
}),
|
|
2081
|
+
...connectors.map((c) => {
|
|
2082
|
+
let icon = c.icon || "https://raw.githubusercontent.com/WalletConnect/walletconnect-assets/master/Logo/Blue%20(Default)/Logo.svg";
|
|
2083
|
+
let name = c.name;
|
|
2084
|
+
if (c.id === "metaMaskSDK" || c.id === "injected" || name.toLowerCase().includes("metamask")) {
|
|
2085
|
+
name = "MetaMask";
|
|
2086
|
+
icon = EVM_WALLET_ICONS.metamask;
|
|
2087
|
+
}
|
|
2088
|
+
if (c.id === "rainbow" || name.toLowerCase().includes("rainbow")) {
|
|
2089
|
+
name = "Rainbow";
|
|
2090
|
+
icon = EVM_WALLET_ICONS.rainbow;
|
|
2091
|
+
}
|
|
2092
|
+
return {
|
|
2093
|
+
id: c.id,
|
|
2094
|
+
name,
|
|
2095
|
+
icon,
|
|
2096
|
+
type: "evm",
|
|
2097
|
+
original: c
|
|
2098
|
+
};
|
|
2099
|
+
})
|
|
2100
|
+
];
|
|
2101
|
+
const filteredWallets = requiredWallets ? allWallets.filter((w) => {
|
|
2102
|
+
const reqLower = requiredWallets.map((s) => s.toLowerCase());
|
|
2103
|
+
return reqLower.includes(w.name.trim().toLowerCase()) || reqLower.includes(w.id.toLowerCase());
|
|
2104
|
+
}) : allWallets;
|
|
2105
|
+
const uniqueWallets = filteredWallets.filter((v, i, a) => a.findIndex((t) => t.name.trim() === v.name.trim()) === i);
|
|
2106
|
+
return /* @__PURE__ */ jsxs3("div", { className: `w-full ${className}`, children: [
|
|
2107
|
+
/* @__PURE__ */ jsxs3("div", { className: "mt-10 px-4 sm:px-6 lg:px-0 lg:pl-[80px]", children: [
|
|
2108
|
+
/* @__PURE__ */ jsx3("h1", { className: "text-[26px] sm:text-[36px] text-white font-bold lg:w-[340px] pb-5", children: "Choose your preferred wallet" }),
|
|
2109
|
+
/* @__PURE__ */ jsx3("p", { className: "text-[16px] text-[#A0A3BD] lg:w-[420px]", children: "VerfifiedOnchain works with any wallet or blockchain and these are the currently integrated wallets." })
|
|
2110
|
+
] }),
|
|
2111
|
+
/* @__PURE__ */ jsxs3("div", { className: "flex flex-col md:flex-row items-center justify-between px-4 sm:px-6 lg:px-[80px]", children: [
|
|
2112
|
+
/* @__PURE__ */ jsx3("div", { className: "w-full md:w-[389px] pt-5 flex flex-col space-y-0", children: uniqueWallets.map((w) => {
|
|
2113
|
+
if (w.type === "solana") {
|
|
2114
|
+
const isActive = connected && wallet?.adapter.name === w.adapterName;
|
|
2115
|
+
const showSpinner = isBusy || connecting;
|
|
2116
|
+
return /* @__PURE__ */ jsxs3(
|
|
2117
|
+
"div",
|
|
2118
|
+
{
|
|
2119
|
+
onClick: async () => {
|
|
2120
|
+
if (isBusy || connecting) return;
|
|
2121
|
+
if (isActive) {
|
|
2122
|
+
try {
|
|
2123
|
+
await disconnect();
|
|
2124
|
+
} catch (error) {
|
|
2125
|
+
console.warn("Solana wallet disconnection error:", error);
|
|
2126
|
+
}
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
setIsBusy(true);
|
|
2130
|
+
try {
|
|
2131
|
+
select(w.adapterName);
|
|
2132
|
+
await Promise.resolve();
|
|
2133
|
+
await connectSolana();
|
|
2134
|
+
onWalletConnect(w.connectSlug);
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
console.warn("Solana wallet connection error:", error);
|
|
2137
|
+
} finally {
|
|
2138
|
+
setIsBusy(false);
|
|
2139
|
+
}
|
|
2140
|
+
},
|
|
2141
|
+
className: "flex items-center space-x-4 hover:bg-[#0D3DDE] transition-colors duration-200 rounded-lg p-3 cursor-pointer border border-gray-800 hover:border-[#0D3DDE] mb-4 w-full",
|
|
2142
|
+
style: { opacity: showSpinner ? 0.7 : 1 },
|
|
2143
|
+
children: [
|
|
2144
|
+
/* @__PURE__ */ jsx3(
|
|
2145
|
+
"img",
|
|
2146
|
+
{
|
|
2147
|
+
src: w.icon,
|
|
2148
|
+
alt: w.name,
|
|
2149
|
+
width: 30,
|
|
2150
|
+
height: 30,
|
|
2151
|
+
className: "bg-white rounded-md p-1 shadow-md shadow-blue-500/20 hover:shadow-blue-500/50 transition-shadow duration-200"
|
|
2152
|
+
}
|
|
2153
|
+
),
|
|
2154
|
+
/* @__PURE__ */ jsx3("span", { className: "text-white font-medium", children: showSpinner ? "Opening..." : isActive ? `Disconnect ${w.name}` : w.name }),
|
|
2155
|
+
isActive && wallet?.adapter.publicKey && /* @__PURE__ */ jsxs3("span", { className: "text-[12px] text-[#A0A3BD]", children: [
|
|
2156
|
+
wallet.adapter.publicKey.toString().slice(0, 6),
|
|
2157
|
+
"...",
|
|
2158
|
+
wallet.adapter.publicKey.toString().slice(-4)
|
|
2159
|
+
] })
|
|
2160
|
+
]
|
|
2161
|
+
},
|
|
2162
|
+
w.id
|
|
2163
|
+
);
|
|
2164
|
+
} else {
|
|
2165
|
+
return /* @__PURE__ */ jsxs3(
|
|
2166
|
+
"div",
|
|
2167
|
+
{
|
|
2168
|
+
onClick: () => {
|
|
2169
|
+
connect({ connector: w.original });
|
|
2170
|
+
onWalletConnect(w.name);
|
|
2171
|
+
},
|
|
2172
|
+
className: "flex items-center space-x-4 hover:bg-[#0D3DDE] transition-colors duration-200 rounded-lg p-3 cursor-pointer border border-gray-800 hover:border-[#0D3DDE] mb-4 w-full",
|
|
2173
|
+
children: [
|
|
2174
|
+
/* @__PURE__ */ jsx3(
|
|
2175
|
+
"img",
|
|
2176
|
+
{
|
|
2177
|
+
src: w.icon,
|
|
2178
|
+
alt: w.name,
|
|
2179
|
+
width: 30,
|
|
2180
|
+
height: 30,
|
|
2181
|
+
className: "bg-white rounded-md p-1 shadow-md shadow-blue-500/20 hover:shadow-blue-500/50 transition-shadow duration-200"
|
|
2182
|
+
}
|
|
2183
|
+
),
|
|
2184
|
+
/* @__PURE__ */ jsx3("span", { className: "text-white font-medium", children: w.name })
|
|
2185
|
+
]
|
|
2186
|
+
},
|
|
2187
|
+
w.id
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
}) }),
|
|
2191
|
+
/* @__PURE__ */ jsx3("div", { className: "mt-10 md:mt-0 flex-shrink-0 flex justify-center md:justify-end", children: /* @__PURE__ */ jsx3(
|
|
2192
|
+
"img",
|
|
2193
|
+
{
|
|
2194
|
+
src: "/cube.gif",
|
|
2195
|
+
alt: "Auth Image",
|
|
2196
|
+
className: "w-full max-w-[280px] sm:max-w-[300px] md:max-w-[400px] h-auto md:h-[400px] object-cover rounded-lg md:ml-10"
|
|
2197
|
+
}
|
|
2198
|
+
) })
|
|
2199
|
+
] })
|
|
2200
|
+
] });
|
|
2201
|
+
};
|
|
2202
|
+
var WalletSelection_default = WalletSelection;
|
|
2203
|
+
|
|
2204
|
+
// src/react/components/wallet/SocialConnection.tsx
|
|
2205
|
+
import { useState as useState6, useEffect as useEffect4 } from "react";
|
|
2206
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
2207
|
+
var SOCIAL_CONNECTION_STORAGE_KEY = "verifiedonchain_social_connection";
|
|
2208
|
+
function persistSocialConnection(platform, profileData) {
|
|
2209
|
+
try {
|
|
2210
|
+
localStorage.setItem(
|
|
2211
|
+
SOCIAL_CONNECTION_STORAGE_KEY,
|
|
2212
|
+
JSON.stringify({
|
|
2213
|
+
platform,
|
|
2214
|
+
profileData,
|
|
2215
|
+
connectedAt: Date.now()
|
|
2216
|
+
})
|
|
2217
|
+
);
|
|
2218
|
+
} catch (err) {
|
|
2219
|
+
console.warn("Failed to persist social connection:", err);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
var SocialConnection = ({
|
|
2223
|
+
onSocialConnect,
|
|
2224
|
+
onNextStep,
|
|
2225
|
+
userAddress,
|
|
2226
|
+
availableSocials,
|
|
2227
|
+
className = "",
|
|
2228
|
+
backendUrl = "https://api.verifiedonchain.com/v1",
|
|
2229
|
+
environment = "mainnet"
|
|
2230
|
+
}) => {
|
|
2231
|
+
const [isConnecting, setIsConnecting] = useState6(null);
|
|
2232
|
+
const [showFarcasterModal, setShowFarcasterModal] = useState6(false);
|
|
2233
|
+
const [showLensModal, setShowLensModal] = useState6(false);
|
|
2234
|
+
const [showLensUsernameModal, setShowLensUsernameModal] = useState6(false);
|
|
2235
|
+
const [showXModal, setShowXModal] = useState6(false);
|
|
2236
|
+
const [farcasterData, setFarcasterData] = useState6(null);
|
|
2237
|
+
const [lensProfile, setLensProfile] = useState6(null);
|
|
2238
|
+
const [xProfile, setXProfile] = useState6(null);
|
|
2239
|
+
const [error, setError] = useState6(null);
|
|
2240
|
+
const [channelToken, setChannelToken] = useState6("");
|
|
2241
|
+
const [qrCodeUrl, setQrCodeUrl] = useState6("");
|
|
2242
|
+
const [pollInterval, setPollInterval] = useState6(null);
|
|
2243
|
+
const [usernameInput, setUsernameInput] = useState6("");
|
|
2244
|
+
const [deepLinkUrl, setDeepLinkUrl] = useState6("");
|
|
2245
|
+
const [isProcessingSuccess, setIsProcessingSuccess] = useState6(false);
|
|
2246
|
+
const [currentNonce, setCurrentNonce] = useState6("");
|
|
2247
|
+
useEffect4(() => {
|
|
2248
|
+
const handleMessage = (event) => {
|
|
2249
|
+
if (event.data?.type === "vo:oauth-success") {
|
|
2250
|
+
const { platform, profile, error: authError } = event.data;
|
|
2251
|
+
if (authError) {
|
|
2252
|
+
setError(authError);
|
|
2253
|
+
setIsConnecting(null);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (profile && platform) {
|
|
2257
|
+
console.log(`\u{1F389} ${platform} Auth completed successfully with profile:`, profile);
|
|
2258
|
+
persistSocialConnection(platform, profile);
|
|
2259
|
+
setIsConnecting(null);
|
|
2260
|
+
onSocialConnect(platform, profile);
|
|
2261
|
+
setTimeout(() => {
|
|
2262
|
+
onNextStep();
|
|
2263
|
+
}, 300);
|
|
2264
|
+
}
|
|
2265
|
+
} else if (event.data?.type === "X_AUTH_RESPONSE") {
|
|
2266
|
+
const { payload, error: authError } = event.data;
|
|
2267
|
+
if (authError) {
|
|
2268
|
+
setError(authError);
|
|
2269
|
+
setIsConnecting(null);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
if (payload) {
|
|
2273
|
+
console.log("\u{1F389} X Auth completed successfully with profile:", payload);
|
|
2274
|
+
setXProfile(payload);
|
|
2275
|
+
setShowXModal(true);
|
|
2276
|
+
handleXSuccess(payload);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
};
|
|
2280
|
+
window.addEventListener("message", handleMessage);
|
|
2281
|
+
return () => window.removeEventListener("message", handleMessage);
|
|
2282
|
+
}, [isProcessingSuccess, onSocialConnect, onNextStep]);
|
|
2283
|
+
useEffect4(() => {
|
|
2284
|
+
return () => {
|
|
2285
|
+
if (pollInterval) {
|
|
2286
|
+
clearInterval(pollInterval);
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
}, [pollInterval]);
|
|
2290
|
+
const startOAuth = (platform) => {
|
|
2291
|
+
setIsConnecting(platform);
|
|
2292
|
+
setError(null);
|
|
2293
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
|
2294
|
+
const params = new URLSearchParams({ origin, network: environment });
|
|
2295
|
+
if (userAddress) params.append("wallet", userAddress);
|
|
2296
|
+
const oauthUrl = `${backendUrl}/oauth/${platform}/start?${params.toString()}`;
|
|
2297
|
+
const width = 600;
|
|
2298
|
+
const height = 800;
|
|
2299
|
+
const left = window.innerWidth / 2 - width / 2 + window.screenX;
|
|
2300
|
+
const top = window.innerHeight / 2 - height / 2 + window.screenY;
|
|
2301
|
+
const popup = window.open(
|
|
2302
|
+
oauthUrl,
|
|
2303
|
+
`${platform}_oauth_popup`,
|
|
2304
|
+
`width=${width},height=${height},top=${top},left=${left},scrollbars=yes,status=no,resizable=no,menubar=no,toolbar=no,location=no`
|
|
2305
|
+
);
|
|
2306
|
+
if (!popup) {
|
|
2307
|
+
setError("Popup was blocked. Please allow popups for this site and try again.");
|
|
2308
|
+
setIsConnecting(null);
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
const checkClosed = setInterval(() => {
|
|
2312
|
+
if (popup.closed) {
|
|
2313
|
+
clearInterval(checkClosed);
|
|
2314
|
+
setIsConnecting((prev) => {
|
|
2315
|
+
if (prev === platform) {
|
|
2316
|
+
setError("Authentication window was closed.");
|
|
2317
|
+
return null;
|
|
2318
|
+
}
|
|
2319
|
+
return prev;
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
}, 1e3);
|
|
2323
|
+
};
|
|
2324
|
+
const handleFarcasterClick = async () => {
|
|
2325
|
+
setIsConnecting("farcaster");
|
|
2326
|
+
setError(null);
|
|
2327
|
+
setIsProcessingSuccess(false);
|
|
2328
|
+
try {
|
|
2329
|
+
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16))).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2330
|
+
setCurrentNonce(nonce);
|
|
2331
|
+
const channelResponse = await fetch("https://relay.farcaster.xyz/v1/channel", {
|
|
2332
|
+
method: "POST",
|
|
2333
|
+
headers: {
|
|
2334
|
+
"Content-Type": "application/json"
|
|
2335
|
+
},
|
|
2336
|
+
body: JSON.stringify({
|
|
2337
|
+
siweUri: window.location.origin,
|
|
2338
|
+
domain: window.location.hostname,
|
|
2339
|
+
nonce
|
|
2340
|
+
})
|
|
2341
|
+
});
|
|
2342
|
+
if (!channelResponse.ok) {
|
|
2343
|
+
const errorText = await channelResponse.text();
|
|
2344
|
+
throw new Error(`Failed to create Farcaster auth channel: ${channelResponse.status}`);
|
|
2345
|
+
}
|
|
2346
|
+
const channelData = await channelResponse.json();
|
|
2347
|
+
if (!channelData.channelToken || !channelData.url) {
|
|
2348
|
+
throw new Error("Invalid response from Farcaster relay - missing token or URL");
|
|
2349
|
+
}
|
|
2350
|
+
setChannelToken(channelData.channelToken);
|
|
2351
|
+
const authUrl = channelData.url;
|
|
2352
|
+
setDeepLinkUrl(authUrl);
|
|
2353
|
+
setQrCodeUrl(`https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(authUrl)}`);
|
|
2354
|
+
setShowFarcasterModal(true);
|
|
2355
|
+
startPolling(channelData.channelToken, nonce);
|
|
2356
|
+
} catch (err) {
|
|
2357
|
+
setError(err.message || "Failed to initialize Farcaster authentication");
|
|
2358
|
+
setIsConnecting(null);
|
|
2359
|
+
}
|
|
2360
|
+
};
|
|
2361
|
+
const startPolling = (token, nonce) => {
|
|
2362
|
+
let pollCount = 0;
|
|
2363
|
+
const maxPolls = 150;
|
|
2364
|
+
const interval = setInterval(async () => {
|
|
2365
|
+
pollCount++;
|
|
2366
|
+
if (pollCount > maxPolls) {
|
|
2367
|
+
clearInterval(interval);
|
|
2368
|
+
setPollInterval(null);
|
|
2369
|
+
setError("Authentication timeout. Please try again.");
|
|
2370
|
+
setIsConnecting(null);
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
try {
|
|
2374
|
+
const statusResponse = await fetch(`https://relay.farcaster.xyz/v1/channel/status?channelToken=${token}`, {
|
|
2375
|
+
method: "GET",
|
|
2376
|
+
headers: {
|
|
2377
|
+
"Content-Type": "application/json",
|
|
2378
|
+
"Authorization": `Bearer ${token}`
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
if (!statusResponse.ok) {
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
const statusData = await statusResponse.json();
|
|
2385
|
+
if (statusData.state === "completed") {
|
|
2386
|
+
clearInterval(interval);
|
|
2387
|
+
setPollInterval(null);
|
|
2388
|
+
const response = statusData.response || statusData;
|
|
2389
|
+
const profileData = {
|
|
2390
|
+
state: statusData.state,
|
|
2391
|
+
nonce: response.nonce || nonce,
|
|
2392
|
+
message: response.message || "",
|
|
2393
|
+
signature: response.signature || "",
|
|
2394
|
+
fid: response.fid || 0,
|
|
2395
|
+
username: response.username || "",
|
|
2396
|
+
bio: response.bio || "",
|
|
2397
|
+
displayName: response.displayName || response.username || "",
|
|
2398
|
+
pfpUrl: response.pfpUrl || ""
|
|
2399
|
+
};
|
|
2400
|
+
setFarcasterData(profileData);
|
|
2401
|
+
handleFarcasterSuccess(profileData);
|
|
2402
|
+
}
|
|
2403
|
+
} catch (err) {
|
|
2404
|
+
}
|
|
2405
|
+
}, 2e3);
|
|
2406
|
+
setPollInterval(interval);
|
|
2407
|
+
};
|
|
2408
|
+
const handleFarcasterSuccess = (profileData) => {
|
|
2409
|
+
if (isProcessingSuccess) {
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
setIsProcessingSuccess(true);
|
|
2413
|
+
if (pollInterval) {
|
|
2414
|
+
clearInterval(pollInterval);
|
|
2415
|
+
setPollInterval(null);
|
|
2416
|
+
}
|
|
2417
|
+
setShowFarcasterModal(false);
|
|
2418
|
+
setIsConnecting(null);
|
|
2419
|
+
const farcasterProfile = {
|
|
2420
|
+
displayName: profileData.displayName,
|
|
2421
|
+
username: profileData.username,
|
|
2422
|
+
pfpUrl: profileData.pfpUrl,
|
|
2423
|
+
bio: profileData.bio,
|
|
2424
|
+
fid: profileData.fid
|
|
2425
|
+
};
|
|
2426
|
+
persistSocialConnection("farcaster", farcasterProfile);
|
|
2427
|
+
onSocialConnect("farcaster", farcasterProfile);
|
|
2428
|
+
setQrCodeUrl("");
|
|
2429
|
+
setChannelToken("");
|
|
2430
|
+
setDeepLinkUrl("");
|
|
2431
|
+
setCurrentNonce("");
|
|
2432
|
+
setTimeout(() => {
|
|
2433
|
+
onNextStep();
|
|
2434
|
+
}, 300);
|
|
2435
|
+
};
|
|
2436
|
+
const closeFarcasterModal = () => {
|
|
2437
|
+
if (pollInterval) {
|
|
2438
|
+
clearInterval(pollInterval);
|
|
2439
|
+
setPollInterval(null);
|
|
2440
|
+
}
|
|
2441
|
+
setShowFarcasterModal(false);
|
|
2442
|
+
setIsConnecting(null);
|
|
2443
|
+
setQrCodeUrl("");
|
|
2444
|
+
setChannelToken("");
|
|
2445
|
+
setDeepLinkUrl("");
|
|
2446
|
+
setCurrentNonce("");
|
|
2447
|
+
setIsProcessingSuccess(false);
|
|
2448
|
+
};
|
|
2449
|
+
const openWarpcast = () => {
|
|
2450
|
+
if (deepLinkUrl) {
|
|
2451
|
+
window.open(deepLinkUrl, "_blank");
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
const handleLensClick = async () => {
|
|
2455
|
+
setError(null);
|
|
2456
|
+
setShowLensUsernameModal(true);
|
|
2457
|
+
};
|
|
2458
|
+
const handleLensUsernameSubmit = async () => {
|
|
2459
|
+
if (!usernameInput.trim()) {
|
|
2460
|
+
setError("Please enter a Lens username");
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
setIsConnecting("lens");
|
|
2464
|
+
setError(null);
|
|
2465
|
+
try {
|
|
2466
|
+
const lensProfileData = await fetchLensProfileByUsername(usernameInput.trim());
|
|
2467
|
+
if (lensProfileData) {
|
|
2468
|
+
if (userAddress) {
|
|
2469
|
+
const accountOwner = lensProfileData.owner?.toLowerCase();
|
|
2470
|
+
const connectedAddress = userAddress.toLowerCase();
|
|
2471
|
+
if (accountOwner !== connectedAddress) {
|
|
2472
|
+
setError("The Lens account owner does not match your connected wallet address.");
|
|
2473
|
+
setIsConnecting(null);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
setLensProfile(lensProfileData);
|
|
2478
|
+
setShowLensUsernameModal(false);
|
|
2479
|
+
setShowLensModal(true);
|
|
2480
|
+
setUsernameInput("");
|
|
2481
|
+
handleLensSuccess(lensProfileData);
|
|
2482
|
+
} else {
|
|
2483
|
+
setError("No Lens profile found for this username");
|
|
2484
|
+
setIsConnecting(null);
|
|
2485
|
+
}
|
|
2486
|
+
} catch (err) {
|
|
2487
|
+
setError(err.message || "Failed to connect to Lens");
|
|
2488
|
+
setIsConnecting(null);
|
|
2489
|
+
}
|
|
2490
|
+
};
|
|
2491
|
+
const closeLensUsernameModal = () => {
|
|
2492
|
+
setShowLensUsernameModal(false);
|
|
2493
|
+
setUsernameInput("");
|
|
2494
|
+
setIsConnecting(null);
|
|
2495
|
+
setError(null);
|
|
2496
|
+
};
|
|
2497
|
+
const fetchLensProfileByUsername = async (username) => {
|
|
2498
|
+
try {
|
|
2499
|
+
const query = `
|
|
2500
|
+
query Account($request: AccountRequest!) {
|
|
2501
|
+
account(request: $request) {
|
|
2502
|
+
owner
|
|
2503
|
+
address
|
|
2504
|
+
metadata {
|
|
2505
|
+
name
|
|
2506
|
+
bio
|
|
2507
|
+
picture
|
|
2508
|
+
coverPicture
|
|
2509
|
+
}
|
|
2510
|
+
username(request: {autoResolve: true}) {
|
|
2511
|
+
namespace
|
|
2512
|
+
localName
|
|
2513
|
+
linkedTo
|
|
2514
|
+
value
|
|
2515
|
+
ownedBy
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
`;
|
|
2520
|
+
const response = await fetch("https://api.lens.xyz/graphql", {
|
|
2521
|
+
method: "POST",
|
|
2522
|
+
headers: {
|
|
2523
|
+
"Content-Type": "application/json"
|
|
2524
|
+
},
|
|
2525
|
+
body: JSON.stringify({
|
|
2526
|
+
query,
|
|
2527
|
+
variables: {
|
|
2528
|
+
request: {
|
|
2529
|
+
username: {
|
|
2530
|
+
localName: username
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
})
|
|
2535
|
+
});
|
|
2536
|
+
const data = await response.json();
|
|
2537
|
+
if (data.errors && data.errors.length > 0) {
|
|
2538
|
+
throw new Error(data.errors[0].message);
|
|
2539
|
+
}
|
|
2540
|
+
if (data.data?.account) {
|
|
2541
|
+
return data.data.account;
|
|
2542
|
+
}
|
|
2543
|
+
return null;
|
|
2544
|
+
} catch (err) {
|
|
2545
|
+
return null;
|
|
2546
|
+
}
|
|
2547
|
+
};
|
|
2548
|
+
const handleLensSuccess = (profileData) => {
|
|
2549
|
+
const pictureUri = profileData.metadata?.picture || "/lens-white.svg";
|
|
2550
|
+
const lensData = {
|
|
2551
|
+
displayName: profileData.metadata?.name || profileData.username?.value || profileData.username?.localName,
|
|
2552
|
+
username: profileData.username?.value || profileData.username?.localName,
|
|
2553
|
+
pfpUrl: pictureUri,
|
|
2554
|
+
bio: profileData.metadata?.bio,
|
|
2555
|
+
address: profileData.address
|
|
2556
|
+
};
|
|
2557
|
+
persistSocialConnection("lens", lensData);
|
|
2558
|
+
onSocialConnect("lens", lensData);
|
|
2559
|
+
setIsConnecting(null);
|
|
2560
|
+
setTimeout(() => {
|
|
2561
|
+
setShowLensModal(false);
|
|
2562
|
+
onNextStep();
|
|
2563
|
+
}, 500);
|
|
2564
|
+
};
|
|
2565
|
+
const closeLensModal = () => {
|
|
2566
|
+
setShowLensModal(false);
|
|
2567
|
+
setIsConnecting(null);
|
|
2568
|
+
};
|
|
2569
|
+
const handleXClick = () => {
|
|
2570
|
+
setIsConnecting("x");
|
|
2571
|
+
setError(null);
|
|
2572
|
+
const width = 600;
|
|
2573
|
+
const height = 800;
|
|
2574
|
+
const left = window.innerWidth / 2 - width / 2 + window.screenX;
|
|
2575
|
+
const top = window.innerHeight / 2 - height / 2 + window.screenY;
|
|
2576
|
+
const popup = window.open(
|
|
2577
|
+
"",
|
|
2578
|
+
"x_login_popup",
|
|
2579
|
+
`width=${width},height=${height},top=${top},left=${left},scrollbars=yes,status=no,resizable=no,menubar=no,toolbar=no,location=no`
|
|
2580
|
+
);
|
|
2581
|
+
if (popup) {
|
|
2582
|
+
popup.location.href = "/api/auth/x/login";
|
|
2583
|
+
} else {
|
|
2584
|
+
setError("Popup was blocked. Please allow popups for this site and try again.");
|
|
2585
|
+
setIsConnecting(null);
|
|
2586
|
+
}
|
|
2587
|
+
};
|
|
2588
|
+
const handleXSuccess = (profileData) => {
|
|
2589
|
+
const xData = {
|
|
2590
|
+
displayName: profileData.displayName,
|
|
2591
|
+
username: profileData.username,
|
|
2592
|
+
pfpUrl: profileData.pfpUrl,
|
|
2593
|
+
bio: profileData.bio || "",
|
|
2594
|
+
address: "",
|
|
2595
|
+
platform: "x"
|
|
2596
|
+
};
|
|
2597
|
+
persistSocialConnection("x", xData);
|
|
2598
|
+
onSocialConnect("x", xData);
|
|
2599
|
+
setIsConnecting(null);
|
|
2600
|
+
setTimeout(() => {
|
|
2601
|
+
setShowXModal(false);
|
|
2602
|
+
onNextStep();
|
|
2603
|
+
}, 500);
|
|
2604
|
+
};
|
|
2605
|
+
const closeXModal = () => {
|
|
2606
|
+
setShowXModal(false);
|
|
2607
|
+
setIsConnecting(null);
|
|
2608
|
+
};
|
|
2609
|
+
const handleClick = (social) => {
|
|
2610
|
+
const s = social.toLowerCase();
|
|
2611
|
+
if (s === "farcaster") {
|
|
2612
|
+
handleFarcasterClick();
|
|
2613
|
+
} else if (s === "lens") {
|
|
2614
|
+
handleLensClick();
|
|
2615
|
+
} else if (s === "x") {
|
|
2616
|
+
handleXClick();
|
|
2617
|
+
} else {
|
|
2618
|
+
startOAuth(s);
|
|
2619
|
+
}
|
|
2620
|
+
};
|
|
2621
|
+
const getPlatformLabel = (social) => {
|
|
2622
|
+
switch (social.toLowerCase()) {
|
|
2623
|
+
case "x":
|
|
2624
|
+
return "(formerly Twitter)";
|
|
2625
|
+
case "farcaster":
|
|
2626
|
+
return "Farcaster";
|
|
2627
|
+
case "lens":
|
|
2628
|
+
return "Lens Protocol";
|
|
2629
|
+
case "youtube":
|
|
2630
|
+
return "YouTube";
|
|
2631
|
+
case "instagram":
|
|
2632
|
+
return "Instagram";
|
|
2633
|
+
case "tiktok":
|
|
2634
|
+
return "TikTok";
|
|
2635
|
+
default:
|
|
2636
|
+
return social.charAt(0).toUpperCase() + social.slice(1);
|
|
2637
|
+
}
|
|
2638
|
+
};
|
|
2639
|
+
const getPlatformIcon = (social) => {
|
|
2640
|
+
switch (social.toLowerCase()) {
|
|
2641
|
+
case "x":
|
|
2642
|
+
return "/x-white.svg";
|
|
2643
|
+
case "farcaster":
|
|
2644
|
+
return "/Farcaster.svg";
|
|
2645
|
+
case "lens":
|
|
2646
|
+
return "/lens-white.svg";
|
|
2647
|
+
case "youtube":
|
|
2648
|
+
return "https://upload.wikimedia.org/wikipedia/commons/0/09/YouTube_full-color_icon_%282017%29.svg";
|
|
2649
|
+
case "instagram":
|
|
2650
|
+
return "https://upload.wikimedia.org/wikipedia/commons/e/e7/Instagram_logo_2016.svg";
|
|
2651
|
+
case "tiktok":
|
|
2652
|
+
return "https://cdn4.iconfinder.com/data/icons/social-media-flat-7/64/Social-media_Tiktok-512.png";
|
|
2653
|
+
default:
|
|
2654
|
+
return "";
|
|
2655
|
+
}
|
|
2656
|
+
};
|
|
2657
|
+
return /* @__PURE__ */ jsxs4("div", { className: `text-center px-4 sm:px-6 lg:px-8 mt-10 ${className}`, children: [
|
|
2658
|
+
/* @__PURE__ */ jsx4("h1", { className: "text-2xl sm:text-3xl lg:text-[36px] font-bold text-white mb-6 sm:mb-8 lg:mb-10", children: "Connect one social identity" }),
|
|
2659
|
+
/* @__PURE__ */ jsx4("p", { className: "text-[#A0A3BD] text-sm sm:text-base max-w-[640px] mx-auto -mt-4 mb-6 sm:mb-8", children: "Link your Lens, Farcaster, or X to anchor your social identity. We only read public profile data. No passwords, no custody, just proof it's you." }),
|
|
2660
|
+
error && /* @__PURE__ */ jsx4("div", { className: "mb-6 bg-red-500 bg-opacity-20 border border-red-500 text-red-200 px-4 py-3 rounded-lg text-sm max-w-[600px] mx-auto", children: error }),
|
|
2661
|
+
/* @__PURE__ */ jsx4("div", { className: "w-full max-w-[600px] mx-auto flex flex-col items-center space-y-4", children: availableSocials.map((social) => {
|
|
2662
|
+
const label = getPlatformLabel(social);
|
|
2663
|
+
const iconUrl = getPlatformIcon(social);
|
|
2664
|
+
return /* @__PURE__ */ jsxs4(
|
|
2665
|
+
"button",
|
|
2666
|
+
{
|
|
2667
|
+
onClick: () => handleClick(social),
|
|
2668
|
+
disabled: isConnecting !== null,
|
|
2669
|
+
className: `flex items-center justify-center space-x-2 sm:space-x-4 transition-colors duration-200 rounded-lg p-3 cursor-pointer border border-gray-800 w-full sm:w-[391px] bg-[#4050590D] ${isConnecting === social ? "opacity-60 cursor-not-allowed" : "hover:bg-[#0D3DDE] hover:border-[#0D3DDE]"}`,
|
|
2670
|
+
children: [
|
|
2671
|
+
iconUrl && /* @__PURE__ */ jsx4(
|
|
2672
|
+
"img",
|
|
2673
|
+
{
|
|
2674
|
+
src: iconUrl,
|
|
2675
|
+
alt: label,
|
|
2676
|
+
className: "w-6 h-6 sm:w-8 sm:h-8 object-contain"
|
|
2677
|
+
}
|
|
2678
|
+
),
|
|
2679
|
+
/* @__PURE__ */ jsx4("span", { className: "text-white font-bold text-base sm:text-lg lg:text-[20px]", children: isConnecting === social ? "Connecting..." : label })
|
|
2680
|
+
]
|
|
2681
|
+
},
|
|
2682
|
+
social
|
|
2683
|
+
);
|
|
2684
|
+
}) }),
|
|
2685
|
+
showFarcasterModal && /* @__PURE__ */ jsx4("div", { className: "fixed inset-0 flex items-center justify-center z-50 p-4 backdrop-blur-sm", children: /* @__PURE__ */ jsxs4("div", { className: "bg-white border border-[#F8FBFF1A] rounded-[20px] p-4 sm:p-6 w-full max-w-[671px] text-black relative", children: [
|
|
2686
|
+
/* @__PURE__ */ jsxs4("div", { className: "flex items-center mb-4 sm:mb-6 relative", children: [
|
|
2687
|
+
/* @__PURE__ */ jsx4("h3", { className: "text-lg sm:text-xl lg:text-[24px] font-bold text-center flex-1", children: "Sign in with Farcaster" }),
|
|
2688
|
+
/* @__PURE__ */ jsx4(
|
|
2689
|
+
"button",
|
|
2690
|
+
{
|
|
2691
|
+
onClick: closeFarcasterModal,
|
|
2692
|
+
className: "absolute right-0 hover:text-gray-400 transition-colors cursor-pointer",
|
|
2693
|
+
children: /* @__PURE__ */ jsx4("svg", { className: "w-5 h-5 sm:w-6 sm:h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
|
|
2694
|
+
}
|
|
2695
|
+
)
|
|
2696
|
+
] }),
|
|
2697
|
+
/* @__PURE__ */ jsxs4("div", { className: "text-center", children: [
|
|
2698
|
+
/* @__PURE__ */ jsx4("p", { className: "text-sm sm:text-base lg:text-[16px] mb-4 font-medium text-[#423B3B] w-full max-w-[341px] mx-auto px-2", children: "To sign with Farcaster scan the code below with your phone's camera." }),
|
|
2699
|
+
qrCodeUrl && /* @__PURE__ */ jsx4(
|
|
2700
|
+
"img",
|
|
2701
|
+
{
|
|
2702
|
+
width: 172,
|
|
2703
|
+
height: 175,
|
|
2704
|
+
src: qrCodeUrl,
|
|
2705
|
+
alt: "Farcaster QR Code",
|
|
2706
|
+
className: "mx-auto mb-4 rounded-lg"
|
|
2707
|
+
}
|
|
2708
|
+
),
|
|
2709
|
+
/* @__PURE__ */ jsx4("hr", { className: "my-4 border-gray-300" }),
|
|
2710
|
+
/* @__PURE__ */ jsx4(
|
|
2711
|
+
"button",
|
|
2712
|
+
{
|
|
2713
|
+
onClick: openWarpcast,
|
|
2714
|
+
className: "w-full mb-4 text-blue-600 font-bold text-[16px] cursor-pointer",
|
|
2715
|
+
children: "I'm using my phone"
|
|
2716
|
+
}
|
|
2717
|
+
),
|
|
2718
|
+
/* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-center space-x-2 text-sm", children: [
|
|
2719
|
+
/* @__PURE__ */ jsxs4("svg", { className: "animate-spin h-4 w-4", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [
|
|
2720
|
+
/* @__PURE__ */ jsx4("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }),
|
|
2721
|
+
/* @__PURE__ */ jsx4("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })
|
|
2722
|
+
] }),
|
|
2723
|
+
/* @__PURE__ */ jsx4("span", { children: "Waiting for authentication..." })
|
|
2724
|
+
] })
|
|
2725
|
+
] })
|
|
2726
|
+
] }) }),
|
|
2727
|
+
showLensUsernameModal && /* @__PURE__ */ jsx4("div", { className: "fixed inset-0 flex items-center justify-center z-50 p-4 backdrop-blur-sm", children: /* @__PURE__ */ jsxs4("div", { className: "bg-[#02081D] border border-[#F8FBFF1A] rounded-[20px] p-6 max-w-md w-full relative", children: [
|
|
2728
|
+
/* @__PURE__ */ jsxs4("div", { className: "flex justify-between items-center mb-6", children: [
|
|
2729
|
+
/* @__PURE__ */ jsx4("h3", { className: "text-lg font-bold text-white", children: "Connect Lens Profile" }),
|
|
2730
|
+
/* @__PURE__ */ jsx4(
|
|
2731
|
+
"button",
|
|
2732
|
+
{
|
|
2733
|
+
onClick: closeLensUsernameModal,
|
|
2734
|
+
className: "text-gray-400 hover:text-white transition-colors cursor-pointer",
|
|
2735
|
+
children: /* @__PURE__ */ jsx4("svg", { className: "w-6 h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
|
|
2736
|
+
}
|
|
2737
|
+
)
|
|
2738
|
+
] }),
|
|
2739
|
+
error && /* @__PURE__ */ jsx4("div", { className: "mb-4 bg-red-500 bg-opacity-20 border border-red-500 text-red-200 px-4 py-3 rounded-lg text-sm", children: error }),
|
|
2740
|
+
/* @__PURE__ */ jsxs4("div", { children: [
|
|
2741
|
+
/* @__PURE__ */ jsxs4("div", { className: "mb-4", children: [
|
|
2742
|
+
/* @__PURE__ */ jsx4("label", { htmlFor: "username", className: "block text-sm font-medium text-white mb-2 text-left", children: "Enter your Lens username" }),
|
|
2743
|
+
/* @__PURE__ */ jsx4(
|
|
2744
|
+
"input",
|
|
2745
|
+
{
|
|
2746
|
+
type: "text",
|
|
2747
|
+
id: "username",
|
|
2748
|
+
value: usernameInput,
|
|
2749
|
+
onChange: (e) => setUsernameInput(e.target.value),
|
|
2750
|
+
onKeyPress: (e) => {
|
|
2751
|
+
if (e.key === "Enter") {
|
|
2752
|
+
handleLensUsernameSubmit();
|
|
2753
|
+
}
|
|
2754
|
+
},
|
|
2755
|
+
placeholder: "username",
|
|
2756
|
+
className: "w-full px-4 py-2 border border-gray-600 rounded-lg bg-[#0A1A2E] text-white placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-[#0D3DDE] focus:border-transparent",
|
|
2757
|
+
disabled: isConnecting === "lens",
|
|
2758
|
+
autoFocus: true
|
|
2759
|
+
}
|
|
2760
|
+
),
|
|
2761
|
+
/* @__PURE__ */ jsx4("p", { className: "text-xs text-gray-400 mt-2 text-left", children: "Enter your Lens username without the @ symbol" })
|
|
2762
|
+
] }),
|
|
2763
|
+
/* @__PURE__ */ jsx4(
|
|
2764
|
+
"button",
|
|
2765
|
+
{
|
|
2766
|
+
onClick: handleLensUsernameSubmit,
|
|
2767
|
+
disabled: isConnecting === "lens" || !usernameInput.trim(),
|
|
2768
|
+
className: `w-full py-3 rounded-lg font-medium transition-colors duration-200 ${isConnecting === "lens" || !usernameInput.trim() ? "bg-gray-700 text-gray-400 cursor-not-allowed" : "bg-[#0D3DDE] hover:bg-[#0A2FB8] text-white"}`,
|
|
2769
|
+
children: isConnecting === "lens" ? "Connecting..." : "Connect"
|
|
2770
|
+
}
|
|
2771
|
+
)
|
|
2772
|
+
] })
|
|
2773
|
+
] }) }),
|
|
2774
|
+
showLensModal && lensProfile && /* @__PURE__ */ jsx4("div", { className: "fixed inset-0 flex items-center justify-center z-50 p-4 backdrop-blur-sm", children: /* @__PURE__ */ jsxs4("div", { className: "bg-[#02081D] border border-[#F8FBFF1A] rounded-[20px] p-6 max-w-sm w-full relative", children: [
|
|
2775
|
+
/* @__PURE__ */ jsxs4("div", { className: "flex justify-between items-center mb-6", children: [
|
|
2776
|
+
/* @__PURE__ */ jsx4("h3", { className: "text-lg font-bold text-white", children: "Lens Connected!" }),
|
|
2777
|
+
/* @__PURE__ */ jsx4(
|
|
2778
|
+
"button",
|
|
2779
|
+
{
|
|
2780
|
+
onClick: closeLensModal,
|
|
2781
|
+
className: "text-gray-400 hover:text-white transition-colors",
|
|
2782
|
+
children: /* @__PURE__ */ jsx4("svg", { className: "w-6 h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
|
|
2783
|
+
}
|
|
2784
|
+
)
|
|
2785
|
+
] }),
|
|
2786
|
+
/* @__PURE__ */ jsxs4("div", { className: "text-center", children: [
|
|
2787
|
+
/* @__PURE__ */ jsx4("div", { className: "w-20 h-20 mx-auto mb-3 rounded-full overflow-hidden bg-gray-700", children: /* @__PURE__ */ jsx4(
|
|
2788
|
+
"img",
|
|
2789
|
+
{
|
|
2790
|
+
src: lensProfile.metadata?.picture || "/lens-white.svg",
|
|
2791
|
+
alt: "Profile",
|
|
2792
|
+
className: "w-full h-full object-cover"
|
|
2793
|
+
}
|
|
2794
|
+
) }),
|
|
2795
|
+
/* @__PURE__ */ jsx4("h4", { className: "text-white font-bold text-lg mb-1", children: lensProfile.metadata?.name || lensProfile.username?.localName || lensProfile.username?.value }),
|
|
2796
|
+
/* @__PURE__ */ jsxs4("p", { className: "text-gray-400 text-sm mb-4", children: [
|
|
2797
|
+
"@",
|
|
2798
|
+
lensProfile.username?.value || lensProfile.username?.localName
|
|
2799
|
+
] }),
|
|
2800
|
+
/* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-center space-x-2 text-[#ABFE2C]", children: [
|
|
2801
|
+
/* @__PURE__ */ jsx4("svg", { className: "w-6 h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M5 13l4 4L19 7" }) }),
|
|
2802
|
+
/* @__PURE__ */ jsx4("span", { className: "font-medium", children: "Successfully connected!" })
|
|
2803
|
+
] })
|
|
2804
|
+
] })
|
|
2805
|
+
] }) }),
|
|
2806
|
+
showXModal && xProfile && /* @__PURE__ */ jsx4("div", { className: "fixed inset-0 flex items-center justify-center z-50 p-4 backdrop-blur-sm", children: /* @__PURE__ */ jsxs4("div", { className: "bg-[#02081D] border border-[#F8FBFF1A] rounded-[20px] p-6 max-w-sm w-full relative", children: [
|
|
2807
|
+
/* @__PURE__ */ jsxs4("div", { className: "flex justify-between items-center mb-6", children: [
|
|
2808
|
+
/* @__PURE__ */ jsx4("h3", { className: "text-lg font-bold text-white", children: "X Connected!" }),
|
|
2809
|
+
/* @__PURE__ */ jsx4(
|
|
2810
|
+
"button",
|
|
2811
|
+
{
|
|
2812
|
+
onClick: closeXModal,
|
|
2813
|
+
className: "text-gray-400 hover:text-white transition-colors",
|
|
2814
|
+
children: /* @__PURE__ */ jsx4("svg", { className: "w-6 h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
|
|
2815
|
+
}
|
|
2816
|
+
)
|
|
2817
|
+
] }),
|
|
2818
|
+
/* @__PURE__ */ jsxs4("div", { className: "text-center", children: [
|
|
2819
|
+
/* @__PURE__ */ jsx4("div", { className: "w-20 h-20 mx-auto mb-3 rounded-full overflow-hidden flex items-center justify-center bg-zinc-900 border border-zinc-800", children: /* @__PURE__ */ jsx4(
|
|
2820
|
+
"img",
|
|
2821
|
+
{
|
|
2822
|
+
src: "/x-white.svg",
|
|
2823
|
+
alt: "X Profile",
|
|
2824
|
+
className: "w-10 h-10 object-contain"
|
|
2825
|
+
}
|
|
2826
|
+
) }),
|
|
2827
|
+
/* @__PURE__ */ jsx4("h4", { className: "text-white font-bold text-lg mb-1", children: xProfile.displayName }),
|
|
2828
|
+
/* @__PURE__ */ jsxs4("p", { className: "text-gray-400 text-sm mb-4", children: [
|
|
2829
|
+
"@",
|
|
2830
|
+
xProfile.username
|
|
2831
|
+
] }),
|
|
2832
|
+
/* @__PURE__ */ jsxs4("div", { className: "flex items-center justify-center space-x-2 text-[#ABFE2C]", children: [
|
|
2833
|
+
/* @__PURE__ */ jsx4("svg", { className: "w-6 h-6", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M5 13l4 4L19 7" }) }),
|
|
2834
|
+
/* @__PURE__ */ jsx4("span", { className: "font-medium", children: "Successfully connected!" })
|
|
2835
|
+
] })
|
|
2836
|
+
] })
|
|
2837
|
+
] }) })
|
|
2838
|
+
] });
|
|
2839
|
+
};
|
|
2840
|
+
var SocialConnection_default = SocialConnection;
|
|
2841
|
+
|
|
2842
|
+
// src/react/components/VerifiedOnchainFlow.tsx
|
|
2843
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
2844
|
+
var VerifiedOnchainFlow = ({
|
|
2845
|
+
relayerUrl,
|
|
2846
|
+
requiredSocials = [],
|
|
2847
|
+
requiredWallets,
|
|
2848
|
+
requireBiometrics = true,
|
|
2849
|
+
onIdentityVerified,
|
|
2850
|
+
className = "",
|
|
2851
|
+
environment = "mainnet",
|
|
2852
|
+
appId
|
|
2853
|
+
}) => {
|
|
2854
|
+
const finalRelayerUrl = relayerUrl || "https://api.verifiedonchain.com";
|
|
2855
|
+
const [stepCount, setStepCount] = useState7(1);
|
|
2856
|
+
const [socialConnected, setSocialConnected] = useState7(false);
|
|
2857
|
+
const [socialProfileData, setSocialProfileData] = useState7(null);
|
|
2858
|
+
const [socialPlatform, setSocialPlatform] = useState7(null);
|
|
2859
|
+
const [requestStatus, setRequestStatus] = useState7("idle");
|
|
2860
|
+
const [humanityCode, setHumanityCode] = useState7(null);
|
|
2861
|
+
const [progressMessage, setProgressMessage] = useState7("");
|
|
2862
|
+
const { isConnected, address } = useAccount();
|
|
2863
|
+
const { connected: solanaConnected, publicKey: solanaPublicKey } = useSolanaWallet();
|
|
2864
|
+
const activeWallet = address ? address : solanaPublicKey ? solanaPublicKey.toString() : null;
|
|
2865
|
+
const activeChain = address ? "evm" : solanaPublicKey ? "solana" : null;
|
|
2866
|
+
useEffect5(() => {
|
|
2867
|
+
if ((isConnected || solanaConnected) && stepCount === 1) {
|
|
2868
|
+
const timer = setTimeout(() => {
|
|
2869
|
+
if (requiredSocials.length > 0) {
|
|
2870
|
+
setStepCount(2);
|
|
2871
|
+
} else if (requireBiometrics) {
|
|
2872
|
+
setStepCount(3);
|
|
2873
|
+
} else {
|
|
2874
|
+
finishFlow();
|
|
2875
|
+
}
|
|
2876
|
+
}, 1e3);
|
|
2877
|
+
return () => clearTimeout(timer);
|
|
2878
|
+
}
|
|
2879
|
+
}, [isConnected, solanaConnected, stepCount, requiredSocials, requireBiometrics]);
|
|
2880
|
+
const handleSocialConnect = async (platform, profileData) => {
|
|
2881
|
+
setSocialConnected(true);
|
|
2882
|
+
setSocialPlatform(platform);
|
|
2883
|
+
if (profileData) setSocialProfileData(profileData);
|
|
2884
|
+
if (requireBiometrics) {
|
|
2885
|
+
setStepCount(3);
|
|
2886
|
+
} else {
|
|
2887
|
+
finishFlow();
|
|
2888
|
+
}
|
|
2889
|
+
};
|
|
2890
|
+
const finishFlow = (simHash, returnedCode) => {
|
|
2891
|
+
setRequestStatus("completed");
|
|
2892
|
+
if (returnedCode) setHumanityCode(returnedCode);
|
|
2893
|
+
onIdentityVerified({
|
|
2894
|
+
walletAddress: activeWallet,
|
|
2895
|
+
chain: activeChain,
|
|
2896
|
+
socialProfile: socialProfileData,
|
|
2897
|
+
socialPlatform: socialPlatform || void 0,
|
|
2898
|
+
humanityCode: returnedCode,
|
|
2899
|
+
simHash
|
|
2900
|
+
});
|
|
2901
|
+
setStepCount(4);
|
|
2902
|
+
};
|
|
2903
|
+
const handleVerificationComplete = async (result) => {
|
|
2904
|
+
setRequestStatus("processing");
|
|
2905
|
+
setStepCount(4);
|
|
2906
|
+
setProgressMessage("Submitting identity to Relayer...");
|
|
2907
|
+
try {
|
|
2908
|
+
const client = new VerifiedRelayerClient({
|
|
2909
|
+
baseUrl: finalRelayerUrl,
|
|
2910
|
+
network: environment,
|
|
2911
|
+
appId
|
|
2912
|
+
});
|
|
2913
|
+
const txResult = await client.submitVerification({
|
|
2914
|
+
simhashFull: result.rawFacialData.faceEmbedding.join(","),
|
|
2915
|
+
// Simplified for SDK
|
|
2916
|
+
livenessCombined: 0.99,
|
|
2917
|
+
// Simplified
|
|
2918
|
+
walletAddress: activeWallet,
|
|
2919
|
+
chain: activeChain
|
|
2920
|
+
});
|
|
2921
|
+
finishFlow(result.rawFacialData.faceEmbedding.join(","), txResult.txHash || "SUCCESS_NO_HASH");
|
|
2922
|
+
} catch (err) {
|
|
2923
|
+
setRequestStatus("error");
|
|
2924
|
+
setHumanityCode(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
2925
|
+
}
|
|
2926
|
+
};
|
|
2927
|
+
return /* @__PURE__ */ jsxs5("div", { className: `w-full max-w-[1000px] mx-auto min-h-[400px] rounded-[20px] p-5 relative overflow-y-auto text-white ${className}`, children: [
|
|
2928
|
+
stepCount === 1 && /* @__PURE__ */ jsx5(
|
|
2929
|
+
WalletSelection_default,
|
|
2930
|
+
{
|
|
2931
|
+
onWalletConnect: () => {
|
|
2932
|
+
},
|
|
2933
|
+
requiredWallets
|
|
2934
|
+
}
|
|
2935
|
+
),
|
|
2936
|
+
stepCount === 2 && /* @__PURE__ */ jsx5(
|
|
2937
|
+
SocialConnection_default,
|
|
2938
|
+
{
|
|
2939
|
+
onSocialConnect: handleSocialConnect,
|
|
2940
|
+
onNextStep: () => setStepCount(3),
|
|
2941
|
+
userAddress: activeWallet || "",
|
|
2942
|
+
availableSocials: requiredSocials,
|
|
2943
|
+
backendUrl: finalRelayerUrl,
|
|
2944
|
+
environment
|
|
2945
|
+
}
|
|
2946
|
+
),
|
|
2947
|
+
stepCount === 3 && /* @__PURE__ */ jsx5(
|
|
2948
|
+
ProofOfHumanity_default,
|
|
2949
|
+
{
|
|
2950
|
+
onNextStep: () => {
|
|
2951
|
+
},
|
|
2952
|
+
onVerificationComplete: handleVerificationComplete
|
|
2953
|
+
}
|
|
2954
|
+
),
|
|
2955
|
+
stepCount === 4 && /* @__PURE__ */ jsxs5("div", { className: "text-center mt-10", children: [
|
|
2956
|
+
/* @__PURE__ */ jsx5("h2", { className: "text-3xl font-bold", children: requestStatus === "error" ? "Verification Failed" : "Verification Complete" }),
|
|
2957
|
+
/* @__PURE__ */ jsx5("p", { className: "mt-4 text-gray-400", children: progressMessage }),
|
|
2958
|
+
humanityCode && /* @__PURE__ */ jsx5("div", { className: "mt-8 p-4 bg-gray-900 rounded-xl", children: /* @__PURE__ */ jsx5("p", { className: "font-mono text-sm break-all", children: humanityCode }) })
|
|
2959
|
+
] })
|
|
2960
|
+
] });
|
|
2961
|
+
};
|
|
2962
|
+
var VerifiedOnchainFlow_default = VerifiedOnchainFlow;
|
|
2963
|
+
export {
|
|
2964
|
+
BACKEND_PRIORITY,
|
|
2965
|
+
CAPTURE_QUALITY,
|
|
2966
|
+
EmbeddingQuantizer,
|
|
2967
|
+
FaceOcclusionTracker,
|
|
2968
|
+
FaceZK_default as FaceZK,
|
|
2969
|
+
GlassesDetector,
|
|
2970
|
+
HUMAN_CONFIG,
|
|
2971
|
+
LivenessEngine,
|
|
2972
|
+
MaskDetector,
|
|
2973
|
+
ProofOfHumanity_default as ProofOfHumanity,
|
|
2974
|
+
SocialConnection_default as SocialConnection,
|
|
2975
|
+
VERIFICATION_CONSTANTS,
|
|
2976
|
+
VerifiedOnchainFlow_default as VerifiedOnchainFlow,
|
|
2977
|
+
VerifiedRelayerClient,
|
|
2978
|
+
WalletSelection_default as WalletSelection,
|
|
2979
|
+
averageEmbeddings,
|
|
2980
|
+
calculateHammingDistance,
|
|
2981
|
+
checkFaceOcclusion,
|
|
2982
|
+
checkFaceOcclusionRaw,
|
|
2983
|
+
computeSimHash,
|
|
2984
|
+
createCalibratedQuantizer,
|
|
2985
|
+
createLivenessData,
|
|
2986
|
+
evaluateCaptureQuality,
|
|
2987
|
+
extractRawFacialData,
|
|
2988
|
+
generateBiometricSimHash,
|
|
2989
|
+
generateHyperplanes,
|
|
2990
|
+
generateRandomSalt,
|
|
2991
|
+
getFaceBox,
|
|
2992
|
+
getFaceLuminance,
|
|
2993
|
+
getHyperplanes,
|
|
2994
|
+
getRegionLuminance,
|
|
2995
|
+
hammingDistance,
|
|
2996
|
+
normalizeEmbedding,
|
|
2997
|
+
processFacialDataForAleo,
|
|
2998
|
+
similarityFromDistance,
|
|
2999
|
+
useCameraStream,
|
|
3000
|
+
useVisionModels,
|
|
3001
|
+
validateDataQuality
|
|
3002
|
+
};
|
|
3003
|
+
//# sourceMappingURL=index.js.map
|