diff --git a/frontend/src/App.js b/frontend/src/App.js index 87ee081..bafd407 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,637 +1,670 @@ -// 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_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' && ( -
-
-

- Step into your style -

-

- Select your action to begin -

-
- - {/* Action Buttons Container */} -
- {/* Take Photo Card */} - - - {/* Upload Photo Card */} - - -
- - -
- )} - { - 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 -

-
-
-
-
- )} - -
-
- ); -}; - +// 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_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; \ No newline at end of file