@rexai/pulse-react 1.0.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 +158 -0
- package/dist/index.d.mts +432 -0
- package/dist/index.d.ts +432 -0
- package/dist/index.js +850 -0
- package/dist/index.mjs +805 -0
- package/dist/styles.css +436 -0
- package/package.json +65 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
// src/components/PulseScanner.tsx
|
|
2
|
+
import { useEffect as useEffect3, useState as useState4, useRef as useRef4, useCallback as useCallback4 } from "react";
|
|
3
|
+
|
|
4
|
+
// src/hooks/useCamera.ts
|
|
5
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
6
|
+
function useCamera(options = {}) {
|
|
7
|
+
const {
|
|
8
|
+
width = 640,
|
|
9
|
+
height = 480,
|
|
10
|
+
facingMode = "user",
|
|
11
|
+
autoStart = false
|
|
12
|
+
} = options;
|
|
13
|
+
const videoRef = useRef(null);
|
|
14
|
+
const streamRef = useRef(null);
|
|
15
|
+
const [isActive, setIsActive] = useState(false);
|
|
16
|
+
const [error, setError] = useState(null);
|
|
17
|
+
const start = useCallback(async () => {
|
|
18
|
+
setError(null);
|
|
19
|
+
try {
|
|
20
|
+
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
21
|
+
video: {
|
|
22
|
+
width: { ideal: width },
|
|
23
|
+
height: { ideal: height },
|
|
24
|
+
facingMode
|
|
25
|
+
},
|
|
26
|
+
audio: false
|
|
27
|
+
});
|
|
28
|
+
streamRef.current = mediaStream;
|
|
29
|
+
if (videoRef.current) {
|
|
30
|
+
videoRef.current.srcObject = mediaStream;
|
|
31
|
+
await new Promise((resolve) => {
|
|
32
|
+
if (videoRef.current) {
|
|
33
|
+
videoRef.current.onloadedmetadata = () => resolve();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
setIsActive(true);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const errorMessage = err.name === "NotAllowedError" ? "Camera access denied. Please allow camera permissions." : err.name === "NotFoundError" ? "No camera found on this device." : err.message || "Failed to access camera.";
|
|
40
|
+
setError(errorMessage);
|
|
41
|
+
setIsActive(false);
|
|
42
|
+
}
|
|
43
|
+
}, [width, height, facingMode]);
|
|
44
|
+
const stop = useCallback(() => {
|
|
45
|
+
if (streamRef.current) {
|
|
46
|
+
streamRef.current.getTracks().forEach((track) => track.stop());
|
|
47
|
+
streamRef.current = null;
|
|
48
|
+
}
|
|
49
|
+
if (videoRef.current) {
|
|
50
|
+
videoRef.current.srcObject = null;
|
|
51
|
+
}
|
|
52
|
+
setIsActive(false);
|
|
53
|
+
}, []);
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (autoStart) {
|
|
56
|
+
start();
|
|
57
|
+
}
|
|
58
|
+
return () => {
|
|
59
|
+
stop();
|
|
60
|
+
};
|
|
61
|
+
}, [autoStart]);
|
|
62
|
+
return {
|
|
63
|
+
videoRef,
|
|
64
|
+
stream: streamRef.current,
|
|
65
|
+
isActive,
|
|
66
|
+
error,
|
|
67
|
+
start,
|
|
68
|
+
stop
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/hooks/useFaceDetection.ts
|
|
73
|
+
import { useState as useState2, useEffect as useEffect2, useRef as useRef2, useCallback as useCallback2 } from "react";
|
|
74
|
+
var FACE_LANDMARKS_TESSELATION = null;
|
|
75
|
+
var FACE_LANDMARKS_RIGHT_IRIS = null;
|
|
76
|
+
var FACE_LANDMARKS_LEFT_IRIS = null;
|
|
77
|
+
var DrawingUtils = null;
|
|
78
|
+
function useFaceDetection(videoElement, options = {}) {
|
|
79
|
+
const { enabled = true, meshCanvas = null, drawMesh = false } = options;
|
|
80
|
+
const [isLoading, setIsLoading] = useState2(true);
|
|
81
|
+
const [landmarker, setLandmarker] = useState2(null);
|
|
82
|
+
const [result, setResult] = useState2({
|
|
83
|
+
detected: false,
|
|
84
|
+
landmarks: null,
|
|
85
|
+
qualityMessage: "Initializing face detection...",
|
|
86
|
+
isQualityGood: false,
|
|
87
|
+
brightness: 0
|
|
88
|
+
});
|
|
89
|
+
const latestLandmarksRef = useRef2(null);
|
|
90
|
+
const brightnessCanvasRef = useRef2(null);
|
|
91
|
+
useEffect2(() => {
|
|
92
|
+
if (!enabled) {
|
|
93
|
+
setIsLoading(false);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const loadMediaPipe = async () => {
|
|
97
|
+
try {
|
|
98
|
+
const mediapipe = await import("@mediapipe/tasks-vision");
|
|
99
|
+
const { FilesetResolver, FaceLandmarker } = mediapipe;
|
|
100
|
+
FACE_LANDMARKS_TESSELATION = FaceLandmarker.FACE_LANDMARKS_TESSELATION;
|
|
101
|
+
FACE_LANDMARKS_RIGHT_IRIS = FaceLandmarker.FACE_LANDMARKS_RIGHT_IRIS;
|
|
102
|
+
FACE_LANDMARKS_LEFT_IRIS = FaceLandmarker.FACE_LANDMARKS_LEFT_IRIS;
|
|
103
|
+
DrawingUtils = mediapipe.DrawingUtils;
|
|
104
|
+
const vision = await FilesetResolver.forVisionTasks(
|
|
105
|
+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm"
|
|
106
|
+
);
|
|
107
|
+
const fl = await FaceLandmarker.createFromOptions(vision, {
|
|
108
|
+
baseOptions: {
|
|
109
|
+
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task`,
|
|
110
|
+
delegate: "GPU"
|
|
111
|
+
},
|
|
112
|
+
outputFaceBlendshapes: false,
|
|
113
|
+
runningMode: "VIDEO",
|
|
114
|
+
numFaces: 1
|
|
115
|
+
});
|
|
116
|
+
setLandmarker(fl);
|
|
117
|
+
setIsLoading(false);
|
|
118
|
+
setResult((prev) => ({
|
|
119
|
+
...prev,
|
|
120
|
+
qualityMessage: "Waiting for camera..."
|
|
121
|
+
}));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error("Failed to load MediaPipe:", err);
|
|
124
|
+
setIsLoading(false);
|
|
125
|
+
setResult((prev) => ({
|
|
126
|
+
...prev,
|
|
127
|
+
qualityMessage: "Face detection unavailable"
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
loadMediaPipe();
|
|
132
|
+
}, [enabled]);
|
|
133
|
+
useEffect2(() => {
|
|
134
|
+
brightnessCanvasRef.current = document.createElement("canvas");
|
|
135
|
+
brightnessCanvasRef.current.width = 64;
|
|
136
|
+
brightnessCanvasRef.current.height = 48;
|
|
137
|
+
}, []);
|
|
138
|
+
const calculateBrightness = useCallback2((video) => {
|
|
139
|
+
const canvas = brightnessCanvasRef.current;
|
|
140
|
+
if (!canvas) return 100;
|
|
141
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
142
|
+
if (!ctx) return 100;
|
|
143
|
+
ctx.drawImage(video, 0, 0, 64, 48);
|
|
144
|
+
const frame = ctx.getImageData(0, 0, 64, 48);
|
|
145
|
+
let sum = 0;
|
|
146
|
+
for (let i = 0; i < frame.data.length; i += 4) {
|
|
147
|
+
sum += (frame.data[i] + frame.data[i + 1] + frame.data[i + 2]) / 3;
|
|
148
|
+
}
|
|
149
|
+
return sum / (64 * 48);
|
|
150
|
+
}, []);
|
|
151
|
+
useEffect2(() => {
|
|
152
|
+
if (!videoElement || !landmarker || !enabled) return;
|
|
153
|
+
let lastTime = -1;
|
|
154
|
+
let frameId;
|
|
155
|
+
let brightnessCheckCounter = 0;
|
|
156
|
+
const detect = () => {
|
|
157
|
+
if (videoElement.paused || videoElement.ended) {
|
|
158
|
+
frameId = requestAnimationFrame(detect);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (videoElement.currentTime !== lastTime && videoElement.readyState >= 2) {
|
|
162
|
+
lastTime = videoElement.currentTime;
|
|
163
|
+
try {
|
|
164
|
+
const results = landmarker.detectForVideo(videoElement, performance.now());
|
|
165
|
+
let brightness = result.brightness;
|
|
166
|
+
brightnessCheckCounter++;
|
|
167
|
+
if (brightnessCheckCounter >= 15) {
|
|
168
|
+
brightness = calculateBrightness(videoElement);
|
|
169
|
+
brightnessCheckCounter = 0;
|
|
170
|
+
}
|
|
171
|
+
if (drawMesh && meshCanvas && DrawingUtils) {
|
|
172
|
+
const ctx = meshCanvas.getContext("2d");
|
|
173
|
+
if (ctx) {
|
|
174
|
+
meshCanvas.width = videoElement.videoWidth;
|
|
175
|
+
meshCanvas.height = videoElement.videoHeight;
|
|
176
|
+
ctx.clearRect(0, 0, meshCanvas.width, meshCanvas.height);
|
|
177
|
+
if (results.faceLandmarks && results.faceLandmarks.length > 0) {
|
|
178
|
+
const drawingUtils = new DrawingUtils(ctx);
|
|
179
|
+
for (const landmarks of results.faceLandmarks) {
|
|
180
|
+
drawingUtils.drawConnectors(
|
|
181
|
+
landmarks,
|
|
182
|
+
FACE_LANDMARKS_TESSELATION,
|
|
183
|
+
{ color: "#30d5c8aa", lineWidth: 1 }
|
|
184
|
+
);
|
|
185
|
+
drawingUtils.drawConnectors(
|
|
186
|
+
landmarks,
|
|
187
|
+
FACE_LANDMARKS_RIGHT_IRIS,
|
|
188
|
+
{ color: "#00FF00", lineWidth: 2 }
|
|
189
|
+
);
|
|
190
|
+
drawingUtils.drawConnectors(
|
|
191
|
+
landmarks,
|
|
192
|
+
FACE_LANDMARKS_LEFT_IRIS,
|
|
193
|
+
{ color: "#00FF00", lineWidth: 2 }
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (results.faceLandmarks && results.faceLandmarks.length > 0) {
|
|
200
|
+
latestLandmarksRef.current = results.faceLandmarks[0];
|
|
201
|
+
let qualityMessage = "Face detected";
|
|
202
|
+
let isQualityGood = true;
|
|
203
|
+
if (brightness < 60) {
|
|
204
|
+
qualityMessage = "Too dark - add more light";
|
|
205
|
+
isQualityGood = false;
|
|
206
|
+
} else if (brightness > 200) {
|
|
207
|
+
qualityMessage = "Too bright - reduce light";
|
|
208
|
+
isQualityGood = false;
|
|
209
|
+
} else {
|
|
210
|
+
qualityMessage = "Face locked. Ready to scan.";
|
|
211
|
+
}
|
|
212
|
+
setResult({
|
|
213
|
+
detected: true,
|
|
214
|
+
landmarks: results.faceLandmarks[0],
|
|
215
|
+
qualityMessage,
|
|
216
|
+
isQualityGood,
|
|
217
|
+
brightness
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
latestLandmarksRef.current = null;
|
|
221
|
+
setResult({
|
|
222
|
+
detected: false,
|
|
223
|
+
landmarks: null,
|
|
224
|
+
qualityMessage: "Position face in frame",
|
|
225
|
+
isQualityGood: false,
|
|
226
|
+
brightness
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
frameId = requestAnimationFrame(detect);
|
|
233
|
+
};
|
|
234
|
+
detect();
|
|
235
|
+
return () => {
|
|
236
|
+
if (frameId) {
|
|
237
|
+
cancelAnimationFrame(frameId);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}, [videoElement, landmarker, enabled, calculateBrightness, result.brightness, drawMesh, meshCanvas]);
|
|
241
|
+
return {
|
|
242
|
+
...result,
|
|
243
|
+
isLoading,
|
|
244
|
+
latestLandmarks: latestLandmarksRef
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/hooks/useRecording.ts
|
|
249
|
+
import { useState as useState3, useRef as useRef3, useCallback as useCallback3 } from "react";
|
|
250
|
+
function useRecording(stream, options = {}) {
|
|
251
|
+
const {
|
|
252
|
+
duration = 10,
|
|
253
|
+
mimeType = "video/webm;codecs=vp9",
|
|
254
|
+
onComplete
|
|
255
|
+
} = options;
|
|
256
|
+
const mediaRecorderRef = useRef3(null);
|
|
257
|
+
const timerRef = useRef3(null);
|
|
258
|
+
const chunksRef = useRef3([]);
|
|
259
|
+
const [isRecording, setIsRecording] = useState3(false);
|
|
260
|
+
const [timeLeft, setTimeLeft] = useState3(duration);
|
|
261
|
+
const [blob, setBlob] = useState3(null);
|
|
262
|
+
const cleanup = useCallback3(() => {
|
|
263
|
+
if (timerRef.current) {
|
|
264
|
+
clearInterval(timerRef.current);
|
|
265
|
+
timerRef.current = null;
|
|
266
|
+
}
|
|
267
|
+
}, []);
|
|
268
|
+
const start = useCallback3(() => {
|
|
269
|
+
if (!stream || isRecording) return;
|
|
270
|
+
chunksRef.current = [];
|
|
271
|
+
setBlob(null);
|
|
272
|
+
setTimeLeft(duration);
|
|
273
|
+
let actualMimeType = mimeType;
|
|
274
|
+
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
|
275
|
+
actualMimeType = "video/webm";
|
|
276
|
+
if (!MediaRecorder.isTypeSupported(actualMimeType)) {
|
|
277
|
+
actualMimeType = "video/mp4";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const recorder = new MediaRecorder(stream, { mimeType: actualMimeType });
|
|
282
|
+
recorder.ondataavailable = (e) => {
|
|
283
|
+
if (e.data.size > 0) {
|
|
284
|
+
chunksRef.current.push(e.data);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
recorder.onstop = () => {
|
|
288
|
+
const videoBlob = new Blob(chunksRef.current, { type: actualMimeType });
|
|
289
|
+
setBlob(videoBlob);
|
|
290
|
+
setIsRecording(false);
|
|
291
|
+
cleanup();
|
|
292
|
+
onComplete?.(videoBlob);
|
|
293
|
+
};
|
|
294
|
+
recorder.onerror = (e) => {
|
|
295
|
+
console.error("MediaRecorder error:", e);
|
|
296
|
+
setIsRecording(false);
|
|
297
|
+
cleanup();
|
|
298
|
+
};
|
|
299
|
+
mediaRecorderRef.current = recorder;
|
|
300
|
+
recorder.start(100);
|
|
301
|
+
setIsRecording(true);
|
|
302
|
+
timerRef.current = setInterval(() => {
|
|
303
|
+
setTimeLeft((prev) => {
|
|
304
|
+
if (prev <= 1) {
|
|
305
|
+
recorder.stop();
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
return prev - 1;
|
|
309
|
+
});
|
|
310
|
+
}, 1e3);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error("Failed to start recording:", err);
|
|
313
|
+
}
|
|
314
|
+
}, [stream, isRecording, duration, mimeType, onComplete, cleanup]);
|
|
315
|
+
const stop = useCallback3(() => {
|
|
316
|
+
if (mediaRecorderRef.current && isRecording) {
|
|
317
|
+
mediaRecorderRef.current.stop();
|
|
318
|
+
}
|
|
319
|
+
}, [isRecording]);
|
|
320
|
+
const reset = useCallback3(() => {
|
|
321
|
+
cleanup();
|
|
322
|
+
setIsRecording(false);
|
|
323
|
+
setTimeLeft(duration);
|
|
324
|
+
setBlob(null);
|
|
325
|
+
chunksRef.current = [];
|
|
326
|
+
}, [duration, cleanup]);
|
|
327
|
+
return {
|
|
328
|
+
isRecording,
|
|
329
|
+
timeLeft,
|
|
330
|
+
blob,
|
|
331
|
+
start,
|
|
332
|
+
stop,
|
|
333
|
+
reset
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/client.ts
|
|
338
|
+
var PulseClient = class {
|
|
339
|
+
constructor(config) {
|
|
340
|
+
if (!config.apiKey) {
|
|
341
|
+
throw new Error("PulseAI SDK: apiKey is required");
|
|
342
|
+
}
|
|
343
|
+
this.apiKey = config.apiKey;
|
|
344
|
+
this.baseUrl = config.baseUrl || typeof import.meta !== "undefined" && import.meta.env?.VITE_API_URL || "http://localhost:8123";
|
|
345
|
+
this.timeout = config.timeout || 6e4;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Analyze video for medical vital signs
|
|
349
|
+
* Returns heart rate, HRV, SpO2, stress index, and more.
|
|
350
|
+
*/
|
|
351
|
+
async analyze(options) {
|
|
352
|
+
const formData = new FormData();
|
|
353
|
+
formData.append("video", options.video, "scan.webm");
|
|
354
|
+
if (options.age !== void 0) {
|
|
355
|
+
formData.append("age", options.age.toString());
|
|
356
|
+
}
|
|
357
|
+
if (options.sex) {
|
|
358
|
+
formData.append("sex", options.sex);
|
|
359
|
+
}
|
|
360
|
+
if (options.weight !== void 0) {
|
|
361
|
+
formData.append("weight", options.weight.toString());
|
|
362
|
+
}
|
|
363
|
+
if (options.height !== void 0) {
|
|
364
|
+
formData.append("height", options.height.toString());
|
|
365
|
+
}
|
|
366
|
+
const response = await this.request("/analyze", formData);
|
|
367
|
+
return response;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Analyze video for beauty/skin metrics
|
|
371
|
+
* Returns skin age, texture, hydration, symmetry, and recommendations.
|
|
372
|
+
*/
|
|
373
|
+
async analyzeBeauty(options) {
|
|
374
|
+
const formData = new FormData();
|
|
375
|
+
formData.append("video", options.video, "beauty_scan.webm");
|
|
376
|
+
if (options.age !== void 0) {
|
|
377
|
+
formData.append("age", options.age.toString());
|
|
378
|
+
}
|
|
379
|
+
if (options.gender) {
|
|
380
|
+
formData.append("gender", options.gender);
|
|
381
|
+
}
|
|
382
|
+
const response = await this.request("/analyze/beauty", formData);
|
|
383
|
+
return response;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Check API health status
|
|
387
|
+
*/
|
|
388
|
+
async health() {
|
|
389
|
+
const response = await fetch(`${this.baseUrl}/health`, {
|
|
390
|
+
method: "GET",
|
|
391
|
+
headers: { "X-API-Key": this.apiKey }
|
|
392
|
+
});
|
|
393
|
+
if (!response.ok) {
|
|
394
|
+
throw new Error("Health check failed");
|
|
395
|
+
}
|
|
396
|
+
return response.json();
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Internal request handler with timeout and error handling
|
|
400
|
+
*/
|
|
401
|
+
async request(endpoint, formData) {
|
|
402
|
+
const controller = new AbortController();
|
|
403
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
404
|
+
try {
|
|
405
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
"X-API-Key": this.apiKey
|
|
409
|
+
// Don't set Content-Type - browser sets it with boundary for FormData
|
|
410
|
+
},
|
|
411
|
+
body: formData,
|
|
412
|
+
signal: controller.signal
|
|
413
|
+
});
|
|
414
|
+
clearTimeout(timeoutId);
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
const errorData = await response.json().catch(() => ({}));
|
|
417
|
+
const message = errorData.detail || `Request failed with status ${response.status}`;
|
|
418
|
+
if (response.status === 401) {
|
|
419
|
+
throw new Error("Invalid API key. Please check your credentials.");
|
|
420
|
+
}
|
|
421
|
+
if (response.status === 402) {
|
|
422
|
+
throw new Error("API quota exceeded. Please upgrade your plan.");
|
|
423
|
+
}
|
|
424
|
+
if (response.status === 403) {
|
|
425
|
+
throw new Error("API key missing. Include X-API-Key header.");
|
|
426
|
+
}
|
|
427
|
+
if (response.status >= 500) {
|
|
428
|
+
throw new Error("Server error. Please try again later.");
|
|
429
|
+
}
|
|
430
|
+
throw new Error(message);
|
|
431
|
+
}
|
|
432
|
+
const result = await response.json();
|
|
433
|
+
if (result.status !== "success") {
|
|
434
|
+
throw new Error(result.message || "Analysis failed");
|
|
435
|
+
}
|
|
436
|
+
return result.data;
|
|
437
|
+
} catch (err) {
|
|
438
|
+
clearTimeout(timeoutId);
|
|
439
|
+
if (err.name === "AbortError") {
|
|
440
|
+
throw new Error("Request timed out. The video may be too long or the connection too slow.");
|
|
441
|
+
}
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// src/components/PulseScanner.tsx
|
|
448
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
449
|
+
var PulseScanner = ({
|
|
450
|
+
apiKey,
|
|
451
|
+
apiUrl,
|
|
452
|
+
duration = 10,
|
|
453
|
+
onResult,
|
|
454
|
+
onError,
|
|
455
|
+
onStatusChange,
|
|
456
|
+
showPreview = true,
|
|
457
|
+
showOverlay = true,
|
|
458
|
+
showMesh = false,
|
|
459
|
+
showCountdown = true,
|
|
460
|
+
className = "",
|
|
461
|
+
theme = "light",
|
|
462
|
+
userData,
|
|
463
|
+
autoStart = false
|
|
464
|
+
}) => {
|
|
465
|
+
const clientRef = useRef4(null);
|
|
466
|
+
const meshCanvasRef = useRef4(null);
|
|
467
|
+
useEffect3(() => {
|
|
468
|
+
clientRef.current = new PulseClient({
|
|
469
|
+
apiKey,
|
|
470
|
+
baseUrl: apiUrl
|
|
471
|
+
// undefined is fine - client will use env var
|
|
472
|
+
});
|
|
473
|
+
}, [apiKey, apiUrl]);
|
|
474
|
+
const {
|
|
475
|
+
videoRef,
|
|
476
|
+
stream,
|
|
477
|
+
isActive: isCameraActive,
|
|
478
|
+
error: cameraError,
|
|
479
|
+
start: startCamera,
|
|
480
|
+
stop: stopCamera
|
|
481
|
+
} = useCamera({ autoStart });
|
|
482
|
+
const {
|
|
483
|
+
detected: faceDetected,
|
|
484
|
+
qualityMessage,
|
|
485
|
+
isQualityGood,
|
|
486
|
+
isLoading: isFaceDetectionLoading
|
|
487
|
+
} = useFaceDetection(videoRef.current, {
|
|
488
|
+
enabled: isCameraActive,
|
|
489
|
+
meshCanvas: meshCanvasRef.current,
|
|
490
|
+
drawMesh: showMesh
|
|
491
|
+
});
|
|
492
|
+
const {
|
|
493
|
+
isRecording,
|
|
494
|
+
timeLeft,
|
|
495
|
+
blob: recordedBlob,
|
|
496
|
+
start: startRecording,
|
|
497
|
+
reset: resetRecording
|
|
498
|
+
} = useRecording(stream, { duration });
|
|
499
|
+
const [status, setStatus] = useState4("idle");
|
|
500
|
+
const [error, setError] = useState4(null);
|
|
501
|
+
useEffect3(() => {
|
|
502
|
+
onStatusChange?.(status);
|
|
503
|
+
}, [status, onStatusChange]);
|
|
504
|
+
useEffect3(() => {
|
|
505
|
+
if (cameraError) {
|
|
506
|
+
setError(cameraError);
|
|
507
|
+
setStatus("error");
|
|
508
|
+
onError?.(new Error(cameraError));
|
|
509
|
+
}
|
|
510
|
+
}, [cameraError, onError]);
|
|
511
|
+
useEffect3(() => {
|
|
512
|
+
if (isFaceDetectionLoading) {
|
|
513
|
+
setStatus("loading");
|
|
514
|
+
} else if (isCameraActive && !isRecording) {
|
|
515
|
+
setStatus("ready");
|
|
516
|
+
}
|
|
517
|
+
}, [isFaceDetectionLoading, isCameraActive, isRecording]);
|
|
518
|
+
useEffect3(() => {
|
|
519
|
+
if (isRecording) {
|
|
520
|
+
setStatus("recording");
|
|
521
|
+
}
|
|
522
|
+
}, [isRecording]);
|
|
523
|
+
useEffect3(() => {
|
|
524
|
+
if (recordedBlob && !isRecording) {
|
|
525
|
+
analyzeVideo(recordedBlob);
|
|
526
|
+
}
|
|
527
|
+
}, [recordedBlob, isRecording]);
|
|
528
|
+
const analyzeVideo = useCallback4(async (videoBlob) => {
|
|
529
|
+
if (!clientRef.current) {
|
|
530
|
+
setError("Client not initialized");
|
|
531
|
+
setStatus("error");
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
setStatus("uploading");
|
|
535
|
+
setError(null);
|
|
536
|
+
try {
|
|
537
|
+
setStatus("analyzing");
|
|
538
|
+
const result = await clientRef.current.analyze({
|
|
539
|
+
video: videoBlob,
|
|
540
|
+
age: userData?.age,
|
|
541
|
+
sex: userData?.sex,
|
|
542
|
+
weight: userData?.weight,
|
|
543
|
+
height: userData?.height
|
|
544
|
+
});
|
|
545
|
+
setStatus("complete");
|
|
546
|
+
onResult(result);
|
|
547
|
+
} catch (err) {
|
|
548
|
+
setError(err.message);
|
|
549
|
+
setStatus("error");
|
|
550
|
+
onError?.(err);
|
|
551
|
+
}
|
|
552
|
+
}, [userData, onResult, onError]);
|
|
553
|
+
const handleStartScan = useCallback4(() => {
|
|
554
|
+
setError(null);
|
|
555
|
+
startRecording();
|
|
556
|
+
}, [startRecording]);
|
|
557
|
+
const handleReset = useCallback4(() => {
|
|
558
|
+
resetRecording();
|
|
559
|
+
setError(null);
|
|
560
|
+
setStatus(isCameraActive ? "ready" : "idle");
|
|
561
|
+
}, [resetRecording, isCameraActive]);
|
|
562
|
+
const handleEnableCamera = useCallback4(async () => {
|
|
563
|
+
setError(null);
|
|
564
|
+
setStatus("loading");
|
|
565
|
+
await startCamera();
|
|
566
|
+
}, [startCamera]);
|
|
567
|
+
useEffect3(() => {
|
|
568
|
+
return () => {
|
|
569
|
+
stopCamera();
|
|
570
|
+
};
|
|
571
|
+
}, [stopCamera]);
|
|
572
|
+
const themeClass = `pulse-scanner--${theme}`;
|
|
573
|
+
const statusClass = `pulse-scanner--${status}`;
|
|
574
|
+
return /* @__PURE__ */ jsxs("div", { className: `pulse-scanner ${themeClass} ${statusClass} ${className}`, children: [
|
|
575
|
+
showPreview && /* @__PURE__ */ jsxs("div", { className: "pulse-scanner__preview", children: [
|
|
576
|
+
/* @__PURE__ */ jsx(
|
|
577
|
+
"video",
|
|
578
|
+
{
|
|
579
|
+
ref: videoRef,
|
|
580
|
+
autoPlay: true,
|
|
581
|
+
playsInline: true,
|
|
582
|
+
muted: true,
|
|
583
|
+
className: "pulse-scanner__video"
|
|
584
|
+
}
|
|
585
|
+
),
|
|
586
|
+
showMesh && /* @__PURE__ */ jsx(
|
|
587
|
+
"canvas",
|
|
588
|
+
{
|
|
589
|
+
ref: meshCanvasRef,
|
|
590
|
+
className: "pulse-scanner__mesh-canvas"
|
|
591
|
+
}
|
|
592
|
+
),
|
|
593
|
+
showOverlay && isCameraActive && /* @__PURE__ */ jsxs("div", { className: "pulse-scanner__overlay", children: [
|
|
594
|
+
/* @__PURE__ */ jsx("div", { className: `pulse-scanner__face-guide ${faceDetected ? "pulse-scanner__face-guide--detected" : ""}` }),
|
|
595
|
+
/* @__PURE__ */ jsx("div", { className: `pulse-scanner__quality ${isQualityGood ? "pulse-scanner__quality--good" : ""}`, children: isRecording ? "Hold still..." : qualityMessage })
|
|
596
|
+
] }),
|
|
597
|
+
showCountdown && isRecording && /* @__PURE__ */ jsxs("div", { className: "pulse-scanner__countdown", children: [
|
|
598
|
+
/* @__PURE__ */ jsxs("div", { className: "pulse-scanner__countdown-ring", children: [
|
|
599
|
+
/* @__PURE__ */ jsx("svg", { viewBox: "0 0 100 100", children: /* @__PURE__ */ jsx(
|
|
600
|
+
"circle",
|
|
601
|
+
{
|
|
602
|
+
cx: "50",
|
|
603
|
+
cy: "50",
|
|
604
|
+
r: "45",
|
|
605
|
+
fill: "none",
|
|
606
|
+
stroke: "currentColor",
|
|
607
|
+
strokeWidth: "4",
|
|
608
|
+
strokeDasharray: `${timeLeft / duration * 283} 283`,
|
|
609
|
+
transform: "rotate(-90 50 50)"
|
|
610
|
+
}
|
|
611
|
+
) }),
|
|
612
|
+
/* @__PURE__ */ jsx("span", { className: "pulse-scanner__countdown-number", children: timeLeft })
|
|
613
|
+
] }),
|
|
614
|
+
/* @__PURE__ */ jsx("span", { className: "pulse-scanner__countdown-label", children: "seconds" })
|
|
615
|
+
] }),
|
|
616
|
+
(status === "uploading" || status === "analyzing") && /* @__PURE__ */ jsxs("div", { className: "pulse-scanner__processing", children: [
|
|
617
|
+
/* @__PURE__ */ jsx("div", { className: "pulse-scanner__spinner" }),
|
|
618
|
+
/* @__PURE__ */ jsx("span", { className: "pulse-scanner__processing-text", children: status === "uploading" ? "Uploading..." : "Analyzing vitals..." })
|
|
619
|
+
] })
|
|
620
|
+
] }),
|
|
621
|
+
/* @__PURE__ */ jsx("div", { className: "pulse-scanner__controls", children: !isCameraActive ? /* @__PURE__ */ jsx(
|
|
622
|
+
"button",
|
|
623
|
+
{
|
|
624
|
+
onClick: handleEnableCamera,
|
|
625
|
+
disabled: status === "loading",
|
|
626
|
+
className: "pulse-scanner__btn pulse-scanner__btn--primary",
|
|
627
|
+
children: status === "loading" ? "Loading..." : "Enable Camera"
|
|
628
|
+
}
|
|
629
|
+
) : status === "complete" || status === "error" ? /* @__PURE__ */ jsx(
|
|
630
|
+
"button",
|
|
631
|
+
{
|
|
632
|
+
onClick: handleReset,
|
|
633
|
+
className: "pulse-scanner__btn pulse-scanner__btn--secondary",
|
|
634
|
+
children: "Scan Again"
|
|
635
|
+
}
|
|
636
|
+
) : /* @__PURE__ */ jsx(
|
|
637
|
+
"button",
|
|
638
|
+
{
|
|
639
|
+
onClick: handleStartScan,
|
|
640
|
+
disabled: isRecording || status === "uploading" || status === "analyzing",
|
|
641
|
+
className: "pulse-scanner__btn pulse-scanner__btn--primary",
|
|
642
|
+
children: isRecording ? `Recording ${timeLeft}s` : status === "uploading" || status === "analyzing" ? "Processing..." : `Start ${duration}s Scan`
|
|
643
|
+
}
|
|
644
|
+
) }),
|
|
645
|
+
error && /* @__PURE__ */ jsxs("div", { className: "pulse-scanner__error", children: [
|
|
646
|
+
/* @__PURE__ */ jsx("span", { className: "pulse-scanner__error-icon", children: "!" }),
|
|
647
|
+
/* @__PURE__ */ jsx("span", { className: "pulse-scanner__error-text", children: error })
|
|
648
|
+
] })
|
|
649
|
+
] });
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
// src/components/VitalsCard.tsx
|
|
653
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
654
|
+
function getHeartRateStatus(hr) {
|
|
655
|
+
if (hr < 55) return { label: "Low", color: "blue" };
|
|
656
|
+
if (hr > 100) return { label: "Elevated", color: "orange" };
|
|
657
|
+
return { label: "Normal", color: "green" };
|
|
658
|
+
}
|
|
659
|
+
function getSpo2Status(spo2) {
|
|
660
|
+
if (spo2 < 95) return { label: "Low", color: "orange" };
|
|
661
|
+
return { label: "Optimal", color: "green" };
|
|
662
|
+
}
|
|
663
|
+
function getStressStatus(stress) {
|
|
664
|
+
if (stress > 70) return { label: "High", color: "red" };
|
|
665
|
+
if (stress < 40) return { label: "Relaxed", color: "green" };
|
|
666
|
+
return { label: "Moderate", color: "blue" };
|
|
667
|
+
}
|
|
668
|
+
function getHrvStatus(hrv) {
|
|
669
|
+
if (hrv < 30) return { label: "Low", color: "orange" };
|
|
670
|
+
if (hrv > 60) return { label: "Excellent", color: "green" };
|
|
671
|
+
return { label: "Balanced", color: "blue" };
|
|
672
|
+
}
|
|
673
|
+
var VitalsCard = ({
|
|
674
|
+
heartRate,
|
|
675
|
+
spo2,
|
|
676
|
+
stressIndex,
|
|
677
|
+
hrvSdnn,
|
|
678
|
+
hrvRmssd,
|
|
679
|
+
respiratoryRate,
|
|
680
|
+
bloodPressure,
|
|
681
|
+
recoveryScore,
|
|
682
|
+
loading = false,
|
|
683
|
+
error = null,
|
|
684
|
+
compact = false,
|
|
685
|
+
showAll = false,
|
|
686
|
+
className = "",
|
|
687
|
+
theme = "light"
|
|
688
|
+
}) => {
|
|
689
|
+
if (loading) {
|
|
690
|
+
return /* @__PURE__ */ jsxs2("div", { className: `vitals-card vitals-card--loading vitals-card--${theme} ${className}`, children: [
|
|
691
|
+
/* @__PURE__ */ jsx2("div", { className: "vitals-card__spinner" }),
|
|
692
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__loading-text", children: "Analyzing vitals..." })
|
|
693
|
+
] });
|
|
694
|
+
}
|
|
695
|
+
if (error) {
|
|
696
|
+
return /* @__PURE__ */ jsxs2("div", { className: `vitals-card vitals-card--error vitals-card--${theme} ${className}`, children: [
|
|
697
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__error-icon", children: "!" }),
|
|
698
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__error-text", children: error })
|
|
699
|
+
] });
|
|
700
|
+
}
|
|
701
|
+
const hrStatus = heartRate !== void 0 ? getHeartRateStatus(heartRate) : null;
|
|
702
|
+
const spo2Status = spo2 !== void 0 ? getSpo2Status(spo2) : null;
|
|
703
|
+
const stressStatus = stressIndex !== void 0 ? getStressStatus(stressIndex) : null;
|
|
704
|
+
const hrvStatus = hrvSdnn !== void 0 ? getHrvStatus(hrvSdnn) : null;
|
|
705
|
+
return /* @__PURE__ */ jsxs2("div", { className: `vitals-card vitals-card--${theme} ${compact ? "vitals-card--compact" : ""} ${className}`, children: [
|
|
706
|
+
/* @__PURE__ */ jsxs2("div", { className: "vitals-card__grid", children: [
|
|
707
|
+
/* @__PURE__ */ jsxs2("div", { className: "vitals-card__item", children: [
|
|
708
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__label", children: "Heart Rate" }),
|
|
709
|
+
/* @__PURE__ */ jsxs2("span", { className: "vitals-card__value", children: [
|
|
710
|
+
heartRate !== void 0 ? heartRate : "--",
|
|
711
|
+
/* @__PURE__ */ jsx2("small", { className: "vitals-card__unit", children: "BPM" })
|
|
712
|
+
] }),
|
|
713
|
+
hrStatus && /* @__PURE__ */ jsx2("span", { className: `vitals-card__status vitals-card__status--${hrStatus.color}`, children: hrStatus.label })
|
|
714
|
+
] }),
|
|
715
|
+
/* @__PURE__ */ jsxs2("div", { className: "vitals-card__item", children: [
|
|
716
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__label", children: "SpO2" }),
|
|
717
|
+
/* @__PURE__ */ jsxs2("span", { className: "vitals-card__value", children: [
|
|
718
|
+
spo2 !== void 0 ? spo2 : "--",
|
|
719
|
+
/* @__PURE__ */ jsx2("small", { className: "vitals-card__unit", children: "%" })
|
|
720
|
+
] }),
|
|
721
|
+
spo2Status && /* @__PURE__ */ jsx2("span", { className: `vitals-card__status vitals-card__status--${spo2Status.color}`, children: spo2Status.label })
|
|
722
|
+
] }),
|
|
723
|
+
/* @__PURE__ */ jsxs2("div", { className: "vitals-card__item", children: [
|
|
724
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__label", children: "Stress" }),
|
|
725
|
+
/* @__PURE__ */ jsxs2("span", { className: "vitals-card__value", children: [
|
|
726
|
+
stressIndex !== void 0 ? stressIndex : "--",
|
|
727
|
+
/* @__PURE__ */ jsx2("small", { className: "vitals-card__unit", children: "/100" })
|
|
728
|
+
] }),
|
|
729
|
+
stressStatus && /* @__PURE__ */ jsx2("span", { className: `vitals-card__status vitals-card__status--${stressStatus.color}`, children: stressStatus.label })
|
|
730
|
+
] }),
|
|
731
|
+
/* @__PURE__ */ jsxs2("div", { className: "vitals-card__item", children: [
|
|
732
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__label", children: "HRV" }),
|
|
733
|
+
/* @__PURE__ */ jsxs2("span", { className: "vitals-card__value", children: [
|
|
734
|
+
hrvSdnn !== void 0 ? hrvSdnn : "--",
|
|
735
|
+
/* @__PURE__ */ jsx2("small", { className: "vitals-card__unit", children: "ms" })
|
|
736
|
+
] }),
|
|
737
|
+
hrvStatus && /* @__PURE__ */ jsx2("span", { className: `vitals-card__status vitals-card__status--${hrvStatus.color}`, children: hrvStatus.label })
|
|
738
|
+
] })
|
|
739
|
+
] }),
|
|
740
|
+
showAll && /* @__PURE__ */ jsxs2("div", { className: "vitals-card__extended", children: [
|
|
741
|
+
respiratoryRate !== void 0 && /* @__PURE__ */ jsxs2("div", { className: "vitals-card__item vitals-card__item--small", children: [
|
|
742
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__label", children: "Respiration" }),
|
|
743
|
+
/* @__PURE__ */ jsxs2("span", { className: "vitals-card__value", children: [
|
|
744
|
+
respiratoryRate,
|
|
745
|
+
/* @__PURE__ */ jsx2("small", { className: "vitals-card__unit", children: "/min" })
|
|
746
|
+
] })
|
|
747
|
+
] }),
|
|
748
|
+
bloodPressure && /* @__PURE__ */ jsxs2("div", { className: "vitals-card__item vitals-card__item--small", children: [
|
|
749
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__label", children: "Blood Pressure" }),
|
|
750
|
+
/* @__PURE__ */ jsxs2("span", { className: "vitals-card__value", children: [
|
|
751
|
+
bloodPressure.systolic,
|
|
752
|
+
"/",
|
|
753
|
+
bloodPressure.diastolic,
|
|
754
|
+
/* @__PURE__ */ jsx2("small", { className: "vitals-card__unit", children: "mmHg" })
|
|
755
|
+
] })
|
|
756
|
+
] }),
|
|
757
|
+
recoveryScore !== void 0 && /* @__PURE__ */ jsxs2("div", { className: "vitals-card__item vitals-card__item--small", children: [
|
|
758
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__label", children: "Recovery" }),
|
|
759
|
+
/* @__PURE__ */ jsxs2("span", { className: "vitals-card__value", children: [
|
|
760
|
+
recoveryScore,
|
|
761
|
+
/* @__PURE__ */ jsx2("small", { className: "vitals-card__unit", children: "%" })
|
|
762
|
+
] })
|
|
763
|
+
] }),
|
|
764
|
+
hrvRmssd !== void 0 && /* @__PURE__ */ jsxs2("div", { className: "vitals-card__item vitals-card__item--small", children: [
|
|
765
|
+
/* @__PURE__ */ jsx2("span", { className: "vitals-card__label", children: "RMSSD" }),
|
|
766
|
+
/* @__PURE__ */ jsxs2("span", { className: "vitals-card__value", children: [
|
|
767
|
+
hrvRmssd,
|
|
768
|
+
/* @__PURE__ */ jsx2("small", { className: "vitals-card__unit", children: "ms" })
|
|
769
|
+
] })
|
|
770
|
+
] })
|
|
771
|
+
] })
|
|
772
|
+
] });
|
|
773
|
+
};
|
|
774
|
+
var VitalsCardFromResult = ({ result, ...props }) => {
|
|
775
|
+
if (!result) {
|
|
776
|
+
return /* @__PURE__ */ jsx2(VitalsCard, { ...props });
|
|
777
|
+
}
|
|
778
|
+
return /* @__PURE__ */ jsx2(
|
|
779
|
+
VitalsCard,
|
|
780
|
+
{
|
|
781
|
+
heartRate: result.heart_rate,
|
|
782
|
+
spo2: result.spo2,
|
|
783
|
+
stressIndex: result.stress_index,
|
|
784
|
+
hrvSdnn: result.hrv_sdnn,
|
|
785
|
+
hrvRmssd: result.hrv_rmssd,
|
|
786
|
+
respiratoryRate: result.respiratory_rate,
|
|
787
|
+
bloodPressure: result.blood_pressure,
|
|
788
|
+
recoveryScore: result.recovery_score,
|
|
789
|
+
...props
|
|
790
|
+
}
|
|
791
|
+
);
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// src/index.ts
|
|
795
|
+
var VERSION = "1.0.0";
|
|
796
|
+
export {
|
|
797
|
+
PulseClient,
|
|
798
|
+
PulseScanner,
|
|
799
|
+
VERSION,
|
|
800
|
+
VitalsCard,
|
|
801
|
+
VitalsCardFromResult,
|
|
802
|
+
useCamera,
|
|
803
|
+
useFaceDetection,
|
|
804
|
+
useRecording
|
|
805
|
+
};
|