Initial commit: added frontend and backend

This commit is contained in:
2026-09-22 12:02:38 +05:30
commit cd2770e590
154 changed files with 25892 additions and 0 deletions
+658
View File
@@ -0,0 +1,658 @@
// 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: #0097C3; 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 () => {
const detections = await skinapi.detectSingleFace(videoRef.current, new skinapi.TinyFaceDetectorOptions());
if (detections) setskinBox(detections.box);
else setskinBox(null);
};
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 ZIP & save analysis ---
try {
const maskZipUrl = result.result?.results?.[0]?.data?.[0]?.url || result.result?.url;
if (!maskZipUrl) throw new Error("No mask ZIP URL found in result");
setZipUrl(maskZipUrl);
const zipRes = await fetch(maskZipUrl);
if (!zipRes.ok) throw new Error(`Failed to download ZIP file (status: ${zipRes.status})`);
const zipBlob = await zipRes.blob();
const zip = await JSZip.loadAsync(zipBlob);
const scoreFile = zip.file("skinanalysisResult/score_info.json") || zip.file("score_info.json");
const scores = scoreFile ? JSON.parse(await scoreFile.async("text")) : result.result?.results?.[0]?.scores || {};
if (Object.keys(scores).length === 0) throw new Error("Could not extract scores from analysis result.");
const newMaskMap = {};
for (const key of Object.keys(scores)) {
const maskName = scores[key].output_mask_name;
if (!maskName) continue;
const f = zip.file(`skinanalysisResult/${maskName}`) || zip.file(`masks/${maskName}`) || zip.file(maskName);
if (!f) continue;
const b = await f.async('blob');
newMaskMap[maskName] = await blobToBase64(b);
}
const originalBase64 = await blobToBase64(await fetch(previewUrl).then(r => r.blob()));
await saveToDB('analysisData', {
scoreData: scores,
maskImages: newMaskMap,
originalImage: originalBase64,
zipDownloadUrl: maskZipUrl
});
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 (
<div className="min-h-screen relative overflow-hidden bg-[#F8F9FA]">
<div className="container mx-auto px-4 py-10 md:py-16 relative z-10 min-h-screen flex flex-col items-center justify-start gap-4 md:gap-8">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-[60] py-4 px-6 flex items-center justify-between bg-[#0097C3] shadow-lg">
<div className="flex items-center gap-4">
<img src={process.env.PUBLIC_URL + '/assets/images/elixify_logo.png'} alt="Elixify Logo" className="h-12 md:h-16 w-auto brightness-0 invert" />
</div>
<div className="flex items-center gap-4">
<button
className="text-white hover:text-white/80 transition-colors"
onClick={() => setShowHelp(!showHelp)}
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{view !== 'landing' && (
<button
className="text-white hover:text-white/80 transition-colors"
onClick={() => {
if (videoStream) {
videoStream.getTracks().forEach(t => t.stop());
setVideoStream(null);
}
setskinBox(null);
setView('landing');
setFile(null);
setPreviewUrl('');
setAnalysisResults(null);
}}
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</header>
<main className="w-full max-w-2xl mx-auto flex flex-col items-center justify-center relative">
{/* Landing View */}
{view === 'landing' && (
<div className="flex flex-col items-center justify-center w-full px-4 gap-8 fade-in mt-20 md:mt-32">
<div className="text-center space-y-4 mb-2">
<h1 className="text-4xl md:text-5xl font-bold text-[#0e5e73] leading-tight tracking-[0.02em] uppercase" style={{ fontFamily: "'Oswald', sans-serif" }}>
AI-POWERED<br />SKIN ANALYSER
</h1>
<p className="text-lg text-gray-700 font-medium tracking-wide max-w-sm mx-auto">
Experience professional-grade skin analysis in seconds
</p>
</div>
{/* Action Buttons Container */}
<div className="w-full flex flex-col gap-4">
{/* Take Photo Card */}
<div
onClick={() => setView('camera')}
className="bg-white border-2 border-dashed border-[#0097C3]/30 p-8 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-[#0097C3] transition-all duration-300 shadow-xl group"
>
<div className="w-16 h-16 bg-[#0097C3]/10 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
<svg className="w-8 h-8 text-[#0097C3]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div className="text-center">
<p className="text-xl font-bold text-black uppercase tracking-widest">Take a Photo</p>
<p className="text-gray-500 text-sm mt-1">Capture using your camera</p>
</div>
</div>
{/* Upload Photo Card */}
<div
onClick={() => document.getElementById('uploadInput').click()}
className="bg-white border-2 border-dashed border-[#0097C3]/30 p-8 flex flex-col items-center justify-center gap-4 cursor-pointer hover:border-[#0097C3] transition-all duration-300 shadow-xl group"
>
<div className="w-16 h-16 bg-[#0097C3]/10 rounded-full flex items-center justify-center group-hover:scale-110 transition-transform">
<svg className="w-8 h-8 text-[#0097C3]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</div>
<div className="text-center">
<p className="text-xl font-bold text-black uppercase tracking-widest">Upload Photo</p>
<p className="text-gray-500 text-sm mt-1">Select from your gallery</p>
</div>
<input
type="file"
id="uploadInput"
accept="image/*"
className="hidden"
onChange={handleFileUpload}
/>
</div>
</div>
{/* Admin Link */}
<div className="mt-8">
<button
onClick={() => navigate('/login')}
className="text-gray-400 hover:text-[#0097C3] transition-colors text-xs uppercase tracking-widest font-bold"
>
Admin Login
</button>
</div>
</div>
)}
{
view === 'camera' && (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="relative flex-1 overflow-hidden">
<video ref={videoRef} autoPlay playsInline className="w-full h-full object-cover transform scale-x-[-1]"></video>
{/* skin Mesh / Wireframe Overlay Placeholder */}
<div className="absolute inset-0 z-10 pointer-events-none">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/40"></div>
</div>
{/* Shutter Button container */}
<div className="absolute bottom-12 left-0 right-0 flex justify-center items-center z-20">
<button
onClick={handleCaptureskin}
className="transform active:scale-95 transition-all cursor-pointer bg-[#0097C3] w-36 h-18 py-4 px-10 rounded-full shadow-[0_0_20px_rgba(0,151,195,0.3)] flex items-center justify-center border-none"
>
<IoMdAperture className="w-12 h-12 text-white" />
</button>
</div>
</div>
</div>
)
}
{/* Preview View */}
{
view === 'preview' && (
<div className="fixed inset-0 z-50 bg-black flex flex-col fade-in">
<div className="relative flex-1 overflow-hidden">
{previewUrl ? (
<img src={previewUrl} alt="Preview" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">No Image</div>
)}
{/* Scanning Animation only when loading */}
{isLoading && (
<div className="absolute inset-0 z-20 pointer-events-none overflow-hidden">
<div className="w-full h-[2px] bg-[#0097C3] shadow-[0_0_20px_rgba(0,151,195,0.8)] absolute top-0 animate-[scan_3s_linear_infinite]"></div>
<div className="w-full h-32 bg-gradient-to-t from-[#0097C3]/20 to-transparent absolute top-0 -translate-y-full animate-[scanTrace_3s_linear_infinite]"></div>
</div>
)}
{/* Bottom Controls */}
<div className="absolute bottom-10 left-0 right-0 flex flex-col items-center justify-center gap-4 z-30 px-6">
<div className="flex items-center gap-4 w-full justify-center">
<button
onClick={handleRetakeClick}
disabled={isLoading}
className="h-14 px-6 bg-white text-black rounded-none flex items-center justify-center gap-2 shadow-lg hover:bg-gray-200 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Retake"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span className="font-bold text-sm tracking-widest uppercase">Retake</span>
</button>
<button
onClick={handleSubmitClick}
disabled={isLoading}
className="h-14 w-20 bg-[#0097C3] text-white rounded-none flex items-center justify-center shadow-lg hover:bg-[#007ba0] transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Confirm"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M1 13l4 4L15 7m-6 6l4 4L23 7" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}
{/* Error Popup */}
{
error && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm p-6 animate-[fadeIn_0.2s_ease-out]">
<div className="bg-white rounded-none shadow-2xl w-full max-w-sm p-8 text-center">
<div className="w-16 h-16 bg-cyan-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-[#0097C3]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">Analysis Failed</h3>
<p className="text-gray-600 mb-8 leading-relaxed text-sm">
{(() => {
const foundCode = Object.keys(errorDescriptions).find(code => error && error.includes(code));
return foundCode ? errorDescriptions[foundCode] : error;
})()}
</p>
<button
onClick={handleRetakeClick}
className="w-full bg-[#0097C3] text-white font-bold py-4 px-6 rounded-none hover:bg-[#007ba0] transition-all active:scale-95 uppercase tracking-widest text-sm"
>
Retake Photo
</button>
</div>
</div>
)
}
</main >
{/* Help Popup */}
{showHelp && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-[fadeIn_0.2s_ease-out]">
<div className="bg-white rounded-none shadow-2xl w-full max-w-sm p-8 relative">
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-bold text-black">Help</h2>
<button
onClick={() => setShowHelp(false)}
className="p-2 -mr-2 text-black hover:bg-gray-100 rounded-full transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Content... */}
</div>
<div className="space-y-6">
<div className="flex items-start gap-4">
<div className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-2.5 shrink-0"></div>
<p className="text-lg text-gray-700 leading-relaxed">
To access previous reports, please contact us at <a href="mailto:hello@elixify.com" className="font-bold text-black">hello@elixify.com</a>
</p>
</div>
<div className="flex items-start gap-4">
<div className="w-1.5 h-1.5 rounded-full bg-gray-400 mt-2.5 shrink-0"></div>
<p className="text-lg text-gray-700 leading-relaxed">
If you experience any technical issues, please contact us at <a href="mailto:support@example.com" className="font-bold text-black">support@example.com</a>
</p>
</div>
</div>
</div>
</div>
)}
</div >
</div >
);
};
export default App;