// 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 */}
{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 */}
)}
{
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;