@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/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
+ };