// src/App.jsx import React, { useState, useRef, useEffect } from 'react'; import JSZip from 'jszip'; import { IoMdAperture } from 'react-icons/io'; import API_BASE_URL from './config'; import * as skinapi from 'face-api.js'; import { useNavigate } from 'react-router'; import { saveToDB } from './db'; import buildInfo from './build-info.json'; const App = () => { const errorDescriptions = { 'error_below_min_image_size': 'Input image resolution is too small', 'error_exceed_max_image_size': 'Input image resolution is too large', 'error_src_skin_too_small': 'The skin area in the uploaded image is too small. The width of the skin needs to be greater than 60% of the width of the image.', 'error_src_face_too_small': 'The face in the uploaded image is too small. Please ensure the face takes up more than 60% of the image width.', 'error_src_skin_out_of_bound': 'The skin area in the uploaded image is out of bound', 'error_lighting_dark': 'The lighting in the uploaded image is too dark' }; const [view, setView] = useState('landing'); const [file, setFile] = useState(null); const [previewUrl, setPreviewUrl] = useState(''); const [videoStream, setVideoStream] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [skinBox, setskinBox] = useState(null); const [modelsLoaded, setModelsLoaded] = useState(false); const [analysisResults, setAnalysisResults] = useState(null); const [maskMap, setMaskMap] = useState({}); const [zipUrl, setZipUrl] = useState(null); const [cropEnabled, setCropEnabled] = useState(true); const [showHelp, setShowHelp] = useState(false); const [landmarks, setLandmarks] = useState(null); const navigate = useNavigate(); const videoRef = useRef(null); const canvasRef = useRef(null); // ========== CONFIGURATION ========== // --- Load skin-api models --- useEffect(() => { console.log(`%c Build Version: ${buildInfo.version} | Date: ${buildInfo.date}`, 'color: limelite-red; font-weight: bold; font-size: 14px;'); const loadModels = async () => { await Promise.all([ skinapi.nets.tinyFaceDetector.loadFromUri(process.env.PUBLIC_URL + '/models'), skinapi.nets.faceLandmark68TinyNet.loadFromUri(process.env.PUBLIC_URL + '/models') ]); setModelsLoaded(true); }; loadModels(); // Set flag for new analysis to allow signup even if number exists sessionStorage.setItem('isNewAnalysis', 'true'); }, []); // --- Real-time skin detection --- useEffect(() => { let intervalId; if (view === 'camera' && videoRef.current && modelsLoaded) { const detectskin = async () => { if (!videoRef.current || videoRef.current.readyState < 2) return; try { const detections = await skinapi.detectSingleFace(videoRef.current, new skinapi.TinyFaceDetectorOptions()); if (detections) setskinBox(detections.box); else setskinBox(null); } catch (e) { console.error("Face detection error:", e); } }; intervalId = setInterval(detectskin, 300); } return () => clearInterval(intervalId); }, [view, modelsLoaded]); // --- Video setup --- useEffect(() => { if (videoRef.current && videoStream) videoRef.current.srcObject = videoStream; }, [videoStream]); // Auto-start camera when view is 'camera' useEffect(() => { let localStream = null; let isMounted = true; if (view === 'camera' && !videoStream) { const startCamera = async () => { setError(null); if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (isMounted) setError('Camera API not accessible. Ensure you are using HTTPS.'); return; } try { // Attempt High-Res User Facing Camera localStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 1920 }, height: { ideal: 1080 } } }); if (!isMounted) { localStream.getTracks().forEach(t => t.stop()); return; } setVideoStream(localStream); } catch (err) { console.warn('Primary camera request failed, attempting fallback...', err); if (!isMounted) return; try { // Fallback to basic constraints localStream = await navigator.mediaDevices.getUserMedia({ video: true }); if (!isMounted) { localStream.getTracks().forEach(t => t.stop()); return; } setVideoStream(localStream); } catch (err2) { console.error('All camera requests failed:', err2); if (!isMounted) return; let msg = 'Could not access the camera.'; if (err2.name === 'NotAllowedError') msg = 'Camera access denied. check permissions.'; else if (err2.name === 'NotFoundError') msg = 'No camera found. Connect a webcam.'; else if (err2.name === 'NotReadableError') msg = 'Camera is in use by another app or tab.'; setError(msg); } } }; startCamera(); } // CLEANUP FUNCTION return () => { isMounted = false; if (localStream) { localStream.getTracks().forEach(track => track.stop()); } }; }, [view]); useEffect(() => { if (view !== 'camera' && videoStream) { videoStream.getTracks().forEach(track => track.stop()); setVideoStream(null); } }, [view, videoStream]); // --- Helper functions --- const safeJsonParse = async (res) => { const text = await res.text(); try { return JSON.parse(text); } catch (e) { console.error("Non-JSON API response:", text.substring(0, 200)); throw new Error("Server returned an invalid non-JSON response. Please check your proxy or API URL configuration."); } }; const resizeImageFile = async (file, maxWidth = 2048, maxHeight = 2048, minShortSide = 1080) => { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { let { width, height } = img; let scaleRatio = 1; if (width > maxWidth || height > maxHeight) scaleRatio = Math.min(maxWidth / width, maxHeight / height); const shortSide = Math.min(width, height); if (shortSide * scaleRatio < minShortSide) scaleRatio = Math.max(minShortSide / shortSide, scaleRatio); const targetWidth = Math.round(width * scaleRatio); const targetHeight = Math.round(height * scaleRatio); const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, targetWidth, targetHeight); canvas.toBlob(blob => { if (!blob) return reject(new Error('Canvas empty')); resolve(new File([blob], file.name.replace(/\.\w+$/, '.jpg'), { type: 'image/jpeg' })); }, 'image/jpeg', 0.9); }; img.onerror = () => reject(new Error('Image load failed')); const reader = new FileReader(); reader.onload = e => img.src = e.target.result; reader.readAsDataURL(file); }); }; const blobToBase64 = blob => new Promise(resolve => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }); const handleFileUpload = (e) => { const file = e.target.files[0]; if (file) { setFile(file); setPreviewUrl(URL.createObjectURL(file)); setView('preview'); setError(null); } }; const handleCameraClick = () => { // Reset to trigger camera initialization via useEffect if (videoStream) { videoStream.getTracks().forEach(t => t.stop()); setVideoStream(null); } setFile(null); setPreviewUrl(''); setView('camera'); setError(null); }; const handleCaptureskin = () => { if (!videoRef.current) return; const video = videoRef.current; // --- Scale Resolution Logic --- const videoWidth = video.videoWidth; const videoHeight = video.videoHeight; // We want higher resolution capture. // Usually video element size is smaller than stream size. // We are using videoHeight/Width which gives the intrinsic size of the stream. const canvas = document.createElement("canvas"); canvas.width = videoWidth; canvas.height = videoHeight; const ctx = canvas.getContext("2d"); // Flip horizontally to match the mirrored viewfinder display ctx.translate(videoWidth, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); let finalCanvas; if (cropEnabled) { // Define crop area (match your overlay) // The overlay is hardcoded as w-40 h-56 in Tailwind (160px x 224px) inside a h-80/96 container. // We need to calculate the proportional area from the actual video video stream. // Since the overlay CSS is relative to the displayed video size, we must compute ratios. // However, for simplicity and robustness in this demo, let's just take a center crop // which is approx 50% width and 70% height of the frame const cropWidth = canvas.width * 0.55; const cropHeight = canvas.height * 0.75; const startX = (canvas.width - cropWidth) / 2; const startY = (canvas.height - cropHeight) / 2; finalCanvas = document.createElement("canvas"); finalCanvas.width = cropWidth; finalCanvas.height = cropHeight; const croppedCtx = finalCanvas.getContext("2d"); croppedCtx.drawImage( canvas, startX, startY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight ); } else { finalCanvas = canvas; } finalCanvas.toBlob((blob) => { const capturedFile = new File([blob], "captured-skin.jpg", { type: "image/jpeg" }); setFile(capturedFile); setPreviewUrl(URL.createObjectURL(capturedFile)); if (videoStream) videoStream.getTracks().forEach((t) => t.stop()); setVideoStream(null); setView("preview"); }, "image/jpeg", 0.95); }; const handleRetakeClick = () => { setView('camera'); setFile(null); setPreviewUrl(''); setAnalysisResults(null); setMaskMap({}); setError(null); }; const handleSubmitClick = async () => { if (!file) return; setIsLoading(true); setError(null); try { // --- Ensure image meets API min size --- let resized; try { resized = await resizeImageFile(file, 2048, 2048, 1080); } catch (e) { throw new Error(`Image resizing failed: ${e.message}`); } // --- Upload & Analyze via Backend Proxy --- let result; try { const formData = new FormData(); formData.append('image', resized); const response = await fetch(`${API_BASE_URL}/api/perfectcorp/analyze`, { method: 'POST', body: formData }); const responseData = await safeJsonParse(response); if (!response.ok) { throw new Error(responseData.error || 'Backend perfectcorp proxy failed'); } result = { result: responseData.result }; // Wrap to match old structure } catch (e) { throw new Error(`Backend Analysis Request failed: ${e.message}`); } // --- Process New S2S JSON Response --- try { const scores = result.result?.scores; if (!scores) throw new Error("No scores found in result from backend."); const originalBase64 = await blobToBase64(await fetch(previewUrl).then(r => r.blob())); const newMaskMap = {}; for (const key of Object.keys(scores)) { const maskUrl = scores[key].output_mask_name; if (!maskUrl) continue; try { // Fetch the image from S3 to cache it locally as base64 const imgRes = await fetch(maskUrl); if (imgRes.ok) { const b = await imgRes.blob(); newMaskMap[maskUrl] = await blobToBase64(b); } else { newMaskMap[maskUrl] = maskUrl; // Fallback to URL } } catch(err) { newMaskMap[maskUrl] = maskUrl; } } await saveToDB('analysisData', { scoreData: scores, maskImages: newMaskMap, originalImage: originalBase64, zipDownloadUrl: null }); setAnalysisResults(scores); setMaskMap(newMaskMap); setView('results'); } catch (e) { throw new Error(`Processing analysis result failed: ${e.message}`); } } catch (e) { console.error(e); setError(`Analysis failed: ${e.message}`); setView('preview'); } finally { setIsLoading(false); } }; useEffect(() => { if (view === 'results') navigate('/result'); }, [view, navigate]); // Auto-start analysis removed to allow manual confirmation with Tick button // --- Render --- return (
{/* Header */}
Limelite Logo
{view !== 'landing' && ( )}
{/* Landing View */} {view === 'landing' && (
{/* Background Ambient Glows */}
{/* Decorative AI Icon */}

Intelligent Face Analysis

Experience our next-generation AI. We analyze your facial features and skin condition to generate a deeply personalized aesthetic report.

Select your action to begin

{/* Action Buttons Container */}
{/* Take Photo Card */} {/* Upload Photo Card */}
{/* Feature Chips */}
Precision Mapping
Deep Skin Analysis
Instant PDF Report
)} { view === 'camera' && (
{/* skin Mesh / Wireframe Overlay Placeholder */}
{/* Shutter Button container */}
) } {/* Preview View */} { view === 'preview' && (
{previewUrl ? ( Preview ) : (
No Image
)} {/* Scanning Animation only when loading */} {isLoading && (
)} {/* Bottom Controls */}
) } {/* Error Popup */} { error && (

Analysis Failed

{(() => { const foundCode = Object.keys(errorDescriptions).find(code => error && error.includes(code)); return foundCode ? errorDescriptions[foundCode] : error; })()}

) }
{/* Help Popup */} {showHelp && (

Help

{/* Content... */}

To access previous reports, please contact us at hello@limelite.com

If you experience any technical issues, please contact us at support@example.com

)}
); }; export default App;