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 */}
-
-
-
-
-
-
- {/* 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 ? (
-

- ) : (
-
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... */}
-
-
-
-
-
- )}
-
-
-
- );
-};
-
+// 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 */}
+
+
+
+
+
+
+ {/* 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 */}
+
+
+
+ )}
+ {
+ view === 'camera' && (
+
+
+
+
+ {/* skin Mesh / Wireframe Overlay Placeholder */}
+
+
+ {/* Shutter Button container */}
+
+
+
+
+
+ )
+ }
+
+ {/* Preview View */}
+ {
+ view === 'preview' && (
+
+
+ {previewUrl ? (
+

+ ) : (
+
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... */}
+
+
+
+
+
+ )}
+
+
+
+ );
+};
+
export default App;
\ No newline at end of file