Create immager
Browse files
immager
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>immager - crop faces easily in bulk</title>
|
| 7 |
+
|
| 8 |
+
<!-- Tailwind CSS for styling -->
|
| 9 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 10 |
+
|
| 11 |
+
<!-- Google Fonts for cinematic logo -->
|
| 12 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 13 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 14 |
+
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;800&display=swap" rel="stylesheet">
|
| 15 |
+
|
| 16 |
+
<!-- React & ReactDOM -->
|
| 17 |
+
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
| 18 |
+
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
| 19 |
+
|
| 20 |
+
<!-- Babel for parsing JSX in the browser -->
|
| 21 |
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
| 22 |
+
|
| 23 |
+
<style>
|
| 24 |
+
/* Custom animations */
|
| 25 |
+
@keyframes spin { 100% { transform: rotate(360deg); } }
|
| 26 |
+
.animate-spin { animation: spin 1s linear infinite; }
|
| 27 |
+
@keyframes slideIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
| 28 |
+
.animate-in { animation: slideIn 0.5s ease-out forwards; }
|
| 29 |
+
</style>
|
| 30 |
+
</head>
|
| 31 |
+
<body class="bg-neutral-950 text-neutral-100 font-sans selection:bg-indigo-500/30">
|
| 32 |
+
<div id="root"></div>
|
| 33 |
+
|
| 34 |
+
<script type="text/babel">
|
| 35 |
+
const { useState, useEffect, useCallback, useRef } = React;
|
| 36 |
+
|
| 37 |
+
// --- Inline Icons (Replaces external icon libraries for offline use) ---
|
| 38 |
+
const LogoIcon = ({size=24, className=""}) => (
|
| 39 |
+
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 512 512" className={className}>
|
| 40 |
+
<rect width="512" height="512" rx="120" fill="#12C369"/>
|
| 41 |
+
<circle cx="380" cy="130" r="55" fill="#ffffff"/>
|
| 42 |
+
<path d="M310 280 L440 430 L180 430 Z" fill="#91E6B3" stroke="#91E6B3" strokeWidth="30" strokeLinejoin="round"/>
|
| 43 |
+
<path d="M220 220 L340 430 L100 430 Z" fill="#ffffff" stroke="#ffffff" strokeWidth="40" strokeLinejoin="round"/>
|
| 44 |
+
</svg>
|
| 45 |
+
);
|
| 46 |
+
const UsersIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>);
|
| 47 |
+
const Trash2Icon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>);
|
| 48 |
+
const UploadCloudIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="M12 12v9"/><path d="m16 16-4-4-4 4"/></svg>);
|
| 49 |
+
const Loader2Icon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>);
|
| 50 |
+
const CheckCircleIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>);
|
| 51 |
+
const ImageIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>);
|
| 52 |
+
const CheckSquareIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>);
|
| 53 |
+
const SquareIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/></svg>);
|
| 54 |
+
const DownloadIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>);
|
| 55 |
+
const SettingsIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>);
|
| 56 |
+
const EditIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>);
|
| 57 |
+
const XIcon = ({size=24, className=""}) => (<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>);
|
| 58 |
+
|
| 59 |
+
// --- Main Application Component ---
|
| 60 |
+
function FaceExtractApp() {
|
| 61 |
+
const [isModelLoading, setIsModelLoading] = useState(true);
|
| 62 |
+
const [isProcessing, setIsProcessing] = useState(false);
|
| 63 |
+
const [progress, setProgress] = useState(0);
|
| 64 |
+
const [statusText, setStatusText] = useState('Initializing Engine...');
|
| 65 |
+
const [faceGroups, setFaceGroups] = useState([]);
|
| 66 |
+
const [selectedFaceIds, setSelectedFaceIds] = useState(new Set());
|
| 67 |
+
const fileInputRef = useRef(null);
|
| 68 |
+
|
| 69 |
+
// New states for real-time reclustering
|
| 70 |
+
const [extractedFaces, setExtractedFaces] = useState([]);
|
| 71 |
+
const [matchThreshold, setMatchThreshold] = useState(0.50);
|
| 72 |
+
|
| 73 |
+
// New Crop Settings States (Updated for strict, very tight cropping by default)
|
| 74 |
+
const [cropSettings, setCropSettings] = useState({ padding: 0.05, topPadding: 0.2, shape: 'square' });
|
| 75 |
+
const [showSettings, setShowSettings] = useState(false);
|
| 76 |
+
const [editingFace, setEditingFace] = useState(null);
|
| 77 |
+
const editorImgRef = useRef(null);
|
| 78 |
+
|
| 79 |
+
// Drag state for manual crop
|
| 80 |
+
const [dragState, setDragState] = useState({ isDragging: false, startX: 0, startY: 0, initialOffsetX: 0, initialOffsetY: 0 });
|
| 81 |
+
|
| 82 |
+
// Drag and drop states for moving faces between groups
|
| 83 |
+
const [dragOverGroupId, setDragOverGroupId] = useState(null);
|
| 84 |
+
const [isDraggingFace, setIsDraggingFace] = useState(false);
|
| 85 |
+
const draggedFaceRef = useRef(null); // Foolproof fallback memory
|
| 86 |
+
|
| 87 |
+
// Load external scripts dynamically (face-api.js and jszip)
|
| 88 |
+
useEffect(() => {
|
| 89 |
+
const loadScripts = async () => {
|
| 90 |
+
const loadScript = (src) => new Promise((resolve, reject) => {
|
| 91 |
+
if (document.querySelector(`script[src="${src}"]`)) return resolve();
|
| 92 |
+
const s = document.createElement('script');
|
| 93 |
+
s.src = src;
|
| 94 |
+
s.async = true;
|
| 95 |
+
s.onload = resolve;
|
| 96 |
+
s.onerror = reject;
|
| 97 |
+
document.body.appendChild(s);
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
try {
|
| 101 |
+
await loadScript('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/dist/face-api.js');
|
| 102 |
+
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js');
|
| 103 |
+
loadAIModels();
|
| 104 |
+
} catch (e) {
|
| 105 |
+
console.error("Failed to load scripts", e);
|
| 106 |
+
setStatusText("Failed to load core libraries. Please check your internet connection.");
|
| 107 |
+
}
|
| 108 |
+
};
|
| 109 |
+
|
| 110 |
+
loadScripts();
|
| 111 |
+
}, []);
|
| 112 |
+
|
| 113 |
+
const loadAIModels = async () => {
|
| 114 |
+
try {
|
| 115 |
+
setStatusText("Loading AI Models (~5MB)...");
|
| 116 |
+
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/';
|
| 117 |
+
|
| 118 |
+
await window.faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL);
|
| 119 |
+
await window.faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL);
|
| 120 |
+
await window.faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL);
|
| 121 |
+
|
| 122 |
+
setIsModelLoading(false);
|
| 123 |
+
setStatusText("Ready");
|
| 124 |
+
} catch (error) {
|
| 125 |
+
console.error(error);
|
| 126 |
+
setStatusText("Error loading AI models.");
|
| 127 |
+
}
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
const generateCrop = (img, box, settings, manualOffsets = { x: 0, y: 0, zoom: 1, resolution: 'auto' }) => {
|
| 131 |
+
const canvas = document.createElement('canvas');
|
| 132 |
+
const ctx = canvas.getContext('2d');
|
| 133 |
+
|
| 134 |
+
let padX = box.width * settings.padding;
|
| 135 |
+
let padY = box.height * settings.padding;
|
| 136 |
+
|
| 137 |
+
let tw = box.width + (padX * 2);
|
| 138 |
+
let th = box.height + padY + (box.height * settings.topPadding);
|
| 139 |
+
|
| 140 |
+
if (settings.shape === 'square') {
|
| 141 |
+
const size = Math.max(tw, th);
|
| 142 |
+
tw = size;
|
| 143 |
+
th = size;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Apply manual zoom (scale)
|
| 147 |
+
tw /= manualOffsets.zoom;
|
| 148 |
+
th /= manualOffsets.zoom;
|
| 149 |
+
|
| 150 |
+
// Base center point mapping
|
| 151 |
+
let cx = box.x + box.width / 2;
|
| 152 |
+
let cy = box.y + box.height / 2 - (box.height * settings.topPadding / 2) + (padY / 2);
|
| 153 |
+
|
| 154 |
+
// Apply manual panning
|
| 155 |
+
cx += manualOffsets.x;
|
| 156 |
+
cy += manualOffsets.y;
|
| 157 |
+
|
| 158 |
+
const cropX = Math.max(0, cx - tw / 2);
|
| 159 |
+
const cropY = Math.max(0, cy - th / 2);
|
| 160 |
+
const cropW = Math.min(img.width - cropX, tw);
|
| 161 |
+
const cropH = Math.min(img.height - cropY, th);
|
| 162 |
+
|
| 163 |
+
// Output Resolution Logic
|
| 164 |
+
let targetW = cropW;
|
| 165 |
+
let targetH = cropH;
|
| 166 |
+
|
| 167 |
+
if (manualOffsets.resolution && manualOffsets.resolution !== 'auto') {
|
| 168 |
+
const res = parseInt(manualOffsets.resolution, 10);
|
| 169 |
+
targetW = res;
|
| 170 |
+
// Maintain proportional aspect ratio if "Original Aspect" is selected in global settings
|
| 171 |
+
targetH = settings.shape === 'original' ? Math.round(res * (cropH / cropW)) : res;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
canvas.width = targetW;
|
| 175 |
+
canvas.height = targetH;
|
| 176 |
+
ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, targetW, targetH);
|
| 177 |
+
return canvas.toDataURL('image/jpeg', 0.9);
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
const openEditor = async (face, groupIndex, faceIndex) => {
|
| 181 |
+
const img = new Image();
|
| 182 |
+
img.src = face.sourceUrl;
|
| 183 |
+
await new Promise(r => img.onload = r);
|
| 184 |
+
editorImgRef.current = img;
|
| 185 |
+
|
| 186 |
+
setEditingFace({
|
| 187 |
+
...face,
|
| 188 |
+
groupIndex,
|
| 189 |
+
faceIndex,
|
| 190 |
+
manualOffsets: face.manualOffsets || { x: 0, y: 0, zoom: 1, resolution: 'auto' },
|
| 191 |
+
previewUrl: face.cropDataUrl
|
| 192 |
+
});
|
| 193 |
+
setDragState({ isDragging: false, startX: 0, startY: 0, initialOffsetX: 0, initialOffsetY: 0 });
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const updateManualCrop = (updates) => {
|
| 197 |
+
if (!editingFace || !editorImgRef.current) return;
|
| 198 |
+
|
| 199 |
+
const newOffsets = { ...editingFace.manualOffsets, ...updates };
|
| 200 |
+
const newPreview = generateCrop(editorImgRef.current, editingFace.originalBox, cropSettings, newOffsets);
|
| 201 |
+
|
| 202 |
+
setEditingFace(prev => ({
|
| 203 |
+
...prev,
|
| 204 |
+
manualOffsets: newOffsets,
|
| 205 |
+
previewUrl: newPreview
|
| 206 |
+
}));
|
| 207 |
+
};
|
| 208 |
+
|
| 209 |
+
const saveManualCrop = () => {
|
| 210 |
+
const newGroups = [...faceGroups];
|
| 211 |
+
newGroups[editingFace.groupIndex].faces[editingFace.faceIndex].cropDataUrl = editingFace.previewUrl;
|
| 212 |
+
newGroups[editingFace.groupIndex].faces[editingFace.faceIndex].manualOffsets = editingFace.manualOffsets;
|
| 213 |
+
setFaceGroups(newGroups);
|
| 214 |
+
setEditingFace(null);
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
+
const updateGroupName = (groupId, newName) => {
|
| 218 |
+
setFaceGroups(prevGroups => prevGroups.map(g =>
|
| 219 |
+
g.id === groupId ? { ...g, name: newName } : g
|
| 220 |
+
));
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
// --- ROCK SOLID DRAG AND DROP ---
|
| 224 |
+
const handleDragStart = (e, faceId, sourceGroupId) => {
|
| 225 |
+
draggedFaceRef.current = { faceId, sourceGroupId };
|
| 226 |
+
e.dataTransfer.effectAllowed = 'move';
|
| 227 |
+
e.dataTransfer.setData('text/plain', faceId);
|
| 228 |
+
|
| 229 |
+
// DEFER state update so layout shift doesn't instantly cancel the drag event
|
| 230 |
+
setTimeout(() => {
|
| 231 |
+
setIsDraggingFace(true);
|
| 232 |
+
}, 0);
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
const handleDragEnd = (e) => {
|
| 236 |
+
setIsDraggingFace(false);
|
| 237 |
+
setDragOverGroupId(null);
|
| 238 |
+
draggedFaceRef.current = null;
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
const handleDragOver = (e, targetGroupId) => {
|
| 242 |
+
e.preventDefault(); // Crucial to allow dropping
|
| 243 |
+
e.dataTransfer.dropEffect = 'move';
|
| 244 |
+
if (dragOverGroupId !== targetGroupId) {
|
| 245 |
+
setDragOverGroupId(targetGroupId);
|
| 246 |
+
}
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
const handleDragLeave = (e) => {
|
| 250 |
+
e.preventDefault();
|
| 251 |
+
// Leave intentionally empty! Reacting to dragLeave causes extreme flickering
|
| 252 |
+
// when dragging over child elements, which aborts the drag operation.
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
const handleDrop = (e, targetGroupId) => {
|
| 256 |
+
e.preventDefault();
|
| 257 |
+
setIsDraggingFace(false);
|
| 258 |
+
setDragOverGroupId(null);
|
| 259 |
+
|
| 260 |
+
const data = draggedFaceRef.current;
|
| 261 |
+
if (!data) return;
|
| 262 |
+
|
| 263 |
+
const { faceId, sourceGroupId } = data;
|
| 264 |
+
if (sourceGroupId === targetGroupId) return;
|
| 265 |
+
|
| 266 |
+
// DEFER DOM mutation until the native drag sequence resolves fully
|
| 267 |
+
setTimeout(() => {
|
| 268 |
+
setFaceGroups(prevGroups => {
|
| 269 |
+
let movedFace = null;
|
| 270 |
+
|
| 271 |
+
// 1. Remove face immutably
|
| 272 |
+
let updatedGroups = prevGroups.map(group => {
|
| 273 |
+
if (group.id === sourceGroupId) {
|
| 274 |
+
const faceIndex = group.faces.findIndex(f => f.id === faceId);
|
| 275 |
+
if (faceIndex > -1) {
|
| 276 |
+
movedFace = group.faces[faceIndex];
|
| 277 |
+
return { ...group, faces: group.faces.filter(f => f.id !== faceId) };
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
return group;
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
if (!movedFace) return prevGroups;
|
| 284 |
+
|
| 285 |
+
// 2. Add face immutably
|
| 286 |
+
if (targetGroupId === 'new-group') {
|
| 287 |
+
updatedGroups.unshift({
|
| 288 |
+
id: `group-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
| 289 |
+
name: '',
|
| 290 |
+
baseDescriptor: movedFace.descriptor,
|
| 291 |
+
faces: [movedFace]
|
| 292 |
+
});
|
| 293 |
+
} else {
|
| 294 |
+
updatedGroups = updatedGroups.map(group => {
|
| 295 |
+
if (group.id === targetGroupId) {
|
| 296 |
+
return { ...group, faces: [...group.faces, movedFace] };
|
| 297 |
+
}
|
| 298 |
+
return group;
|
| 299 |
+
});
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// 3. Clean up
|
| 303 |
+
return updatedGroups.filter(g => g.faces.length > 0);
|
| 304 |
+
});
|
| 305 |
+
}, 0);
|
| 306 |
+
};
|
| 307 |
+
|
| 308 |
+
const applyGlobalCropSettings = async (newSettings) => {
|
| 309 |
+
setCropSettings(newSettings);
|
| 310 |
+
if (extractedFaces.length === 0) return;
|
| 311 |
+
|
| 312 |
+
setIsProcessing(true);
|
| 313 |
+
setStatusText("Applying new crop settings...");
|
| 314 |
+
|
| 315 |
+
// Small delay to let React render the loading state before heavy calculation
|
| 316 |
+
await new Promise(resolve => setTimeout(resolve, 50));
|
| 317 |
+
|
| 318 |
+
try {
|
| 319 |
+
const imageCache = {};
|
| 320 |
+
const updatedFaces = [];
|
| 321 |
+
|
| 322 |
+
for (let i = 0; i < extractedFaces.length; i++) {
|
| 323 |
+
const face = extractedFaces[i];
|
| 324 |
+
let img = imageCache[face.sourceUrl];
|
| 325 |
+
if (!img) {
|
| 326 |
+
img = new Image();
|
| 327 |
+
img.src = face.sourceUrl;
|
| 328 |
+
await new Promise(r => img.onload = r);
|
| 329 |
+
imageCache[face.sourceUrl] = img;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
const newCropDataUrl = generateCrop(img, face.originalBox, newSettings, face.manualOffsets || { x: 0, y: 0, zoom: 1, resolution: 'auto' });
|
| 333 |
+
updatedFaces.push({ ...face, cropDataUrl: newCropDataUrl });
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
setExtractedFaces(updatedFaces);
|
| 337 |
+
|
| 338 |
+
// Update faceGroups to preserve names, groupings, and selections
|
| 339 |
+
setFaceGroups(prevGroups => prevGroups.map(group => ({
|
| 340 |
+
...group,
|
| 341 |
+
faces: group.faces.map(gFace => {
|
| 342 |
+
const updatedFace = updatedFaces.find(uf => uf.id === gFace.id);
|
| 343 |
+
return updatedFace ? { ...gFace, cropDataUrl: updatedFace.cropDataUrl } : gFace;
|
| 344 |
+
})
|
| 345 |
+
})));
|
| 346 |
+
} catch (error) {
|
| 347 |
+
console.error("Error applying crop settings:", error);
|
| 348 |
+
} finally {
|
| 349 |
+
setIsProcessing(false);
|
| 350 |
+
setStatusText("Done");
|
| 351 |
+
}
|
| 352 |
+
};
|
| 353 |
+
|
| 354 |
+
const clusterFaces = useCallback((facesToCluster, threshold) => {
|
| 355 |
+
const groups = [];
|
| 356 |
+
facesToCluster.forEach(face => {
|
| 357 |
+
let foundGroup = false;
|
| 358 |
+
for (let group of groups) {
|
| 359 |
+
const distance = window.faceapi.euclideanDistance(group.baseDescriptor, face.descriptor);
|
| 360 |
+
if (distance < threshold) {
|
| 361 |
+
group.faces.push(face);
|
| 362 |
+
foundGroup = true;
|
| 363 |
+
break;
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
if (!foundGroup) {
|
| 367 |
+
groups.push({
|
| 368 |
+
id: `group-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
| 369 |
+
name: '',
|
| 370 |
+
baseDescriptor: face.descriptor,
|
| 371 |
+
faces: [face]
|
| 372 |
+
});
|
| 373 |
+
}
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
// Sort groups by most frequent faces
|
| 377 |
+
groups.sort((a, b) => b.faces.length - a.faces.length);
|
| 378 |
+
setFaceGroups(groups);
|
| 379 |
+
}, []);
|
| 380 |
+
|
| 381 |
+
const processImages = async (files) => {
|
| 382 |
+
if (!files || files.length === 0) return;
|
| 383 |
+
|
| 384 |
+
setIsProcessing(true);
|
| 385 |
+
setFaceGroups([]);
|
| 386 |
+
setExtractedFaces([]);
|
| 387 |
+
setSelectedFaceIds(new Set());
|
| 388 |
+
const allExtractedFaces = [];
|
| 389 |
+
|
| 390 |
+
for (let i = 0; i < files.length; i++) {
|
| 391 |
+
const file = files[i];
|
| 392 |
+
if (!file.type.startsWith('image/')) continue;
|
| 393 |
+
|
| 394 |
+
setStatusText(`Scanning image ${i + 1} of ${files.length}...`);
|
| 395 |
+
setProgress(((i) / files.length) * 100);
|
| 396 |
+
|
| 397 |
+
try {
|
| 398 |
+
const img = await new Promise((resolve, reject) => {
|
| 399 |
+
const reader = new FileReader();
|
| 400 |
+
reader.onload = (e) => {
|
| 401 |
+
const image = new Image();
|
| 402 |
+
image.src = e.target.result;
|
| 403 |
+
image.onload = () => resolve(image);
|
| 404 |
+
image.onerror = reject;
|
| 405 |
+
};
|
| 406 |
+
reader.onerror = reject;
|
| 407 |
+
reader.readAsDataURL(file);
|
| 408 |
+
});
|
| 409 |
+
|
| 410 |
+
const detections = await window.faceapi.detectAllFaces(img)
|
| 411 |
+
.withFaceLandmarks()
|
| 412 |
+
.withFaceDescriptors();
|
| 413 |
+
|
| 414 |
+
const sourceUrl = URL.createObjectURL(file);
|
| 415 |
+
|
| 416 |
+
detections.forEach((det, idx) => {
|
| 417 |
+
const cropDataUrl = generateCrop(img, det.detection.box, cropSettings);
|
| 418 |
+
allExtractedFaces.push({
|
| 419 |
+
id: `face-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
| 420 |
+
sourceFile: file.name,
|
| 421 |
+
sourceUrl: sourceUrl,
|
| 422 |
+
originalBox: det.detection.box,
|
| 423 |
+
cropDataUrl,
|
| 424 |
+
descriptor: det.descriptor
|
| 425 |
+
});
|
| 426 |
+
});
|
| 427 |
+
} catch (err) {
|
| 428 |
+
console.error(`Error processing ${file.name}`, err);
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
setStatusText("Organizing faces by person...");
|
| 433 |
+
|
| 434 |
+
// Save to state and run initial clustering
|
| 435 |
+
setExtractedFaces(allExtractedFaces);
|
| 436 |
+
clusterFaces(allExtractedFaces, matchThreshold);
|
| 437 |
+
|
| 438 |
+
setIsProcessing(false);
|
| 439 |
+
setProgress(100);
|
| 440 |
+
setStatusText("Done");
|
| 441 |
+
};
|
| 442 |
+
|
| 443 |
+
const onDrop = useCallback((e) => {
|
| 444 |
+
e.preventDefault();
|
| 445 |
+
if (isModelLoading || isProcessing) return;
|
| 446 |
+
const files = Array.from(e.dataTransfer.files);
|
| 447 |
+
processImages(files);
|
| 448 |
+
}, [isModelLoading, isProcessing]);
|
| 449 |
+
|
| 450 |
+
const onFileChange = (e) => {
|
| 451 |
+
if (e.target.files && e.target.files.length > 0) {
|
| 452 |
+
processImages(Array.from(e.target.files));
|
| 453 |
+
}
|
| 454 |
+
};
|
| 455 |
+
|
| 456 |
+
const toggleFace = (id) => {
|
| 457 |
+
const next = new Set(selectedFaceIds);
|
| 458 |
+
if (next.has(id)) {
|
| 459 |
+
next.delete(id);
|
| 460 |
+
} else {
|
| 461 |
+
next.add(id);
|
| 462 |
+
}
|
| 463 |
+
setSelectedFaceIds(next);
|
| 464 |
+
};
|
| 465 |
+
|
| 466 |
+
const toggleGroup = (group) => {
|
| 467 |
+
const next = new Set(selectedFaceIds);
|
| 468 |
+
const allSelected = group.faces.every(f => next.has(f.id));
|
| 469 |
+
|
| 470 |
+
group.faces.forEach(f => {
|
| 471 |
+
if (allSelected) {
|
| 472 |
+
next.delete(f.id);
|
| 473 |
+
} else {
|
| 474 |
+
next.add(f.id);
|
| 475 |
+
}
|
| 476 |
+
});
|
| 477 |
+
setSelectedFaceIds(next);
|
| 478 |
+
};
|
| 479 |
+
|
| 480 |
+
const clearAll = () => {
|
| 481 |
+
setFaceGroups([]);
|
| 482 |
+
setExtractedFaces([]);
|
| 483 |
+
setSelectedFaceIds(new Set());
|
| 484 |
+
setProgress(0);
|
| 485 |
+
};
|
| 486 |
+
|
| 487 |
+
const downloadSelected = async () => {
|
| 488 |
+
if (selectedFaceIds.size === 0) return;
|
| 489 |
+
|
| 490 |
+
setIsProcessing(true);
|
| 491 |
+
setStatusText("Generating ZIP archive...");
|
| 492 |
+
|
| 493 |
+
try {
|
| 494 |
+
const zip = new window.JSZip();
|
| 495 |
+
let totalExported = 0;
|
| 496 |
+
|
| 497 |
+
faceGroups.forEach((group, gIndex) => {
|
| 498 |
+
// Determine folder and file names based on user input
|
| 499 |
+
const hasName = group.name && group.name.trim() !== '';
|
| 500 |
+
const folderName = hasName ? group.name.trim() : `Person_${gIndex + 1}`;
|
| 501 |
+
const filePrefix = hasName ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : 'face';
|
| 502 |
+
|
| 503 |
+
const selectedInGroup = group.faces.filter(f => selectedFaceIds.has(f.id));
|
| 504 |
+
|
| 505 |
+
if (selectedInGroup.length > 0) {
|
| 506 |
+
const folder = zip.folder(folderName);
|
| 507 |
+
selectedInGroup.forEach((face, fIndex) => {
|
| 508 |
+
const base64Data = face.cropDataUrl.split(',')[1];
|
| 509 |
+
const fileName = hasName ? `${filePrefix}${fIndex + 1}.jpg` : `face_${fIndex + 1}.jpg`;
|
| 510 |
+
folder.file(fileName, base64Data, {base64: true});
|
| 511 |
+
totalExported++;
|
| 512 |
+
});
|
| 513 |
+
}
|
| 514 |
+
});
|
| 515 |
+
|
| 516 |
+
const content = await zip.generateAsync({type: "blob"});
|
| 517 |
+
const url = URL.createObjectURL(content);
|
| 518 |
+
const a = document.createElement("a");
|
| 519 |
+
a.href = url;
|
| 520 |
+
a.download = `Extracted_Faces_${totalExported}.zip`;
|
| 521 |
+
document.body.appendChild(a);
|
| 522 |
+
a.click();
|
| 523 |
+
document.body.removeChild(a);
|
| 524 |
+
URL.revokeObjectURL(url);
|
| 525 |
+
} catch (e) {
|
| 526 |
+
console.error("ZIP Generation Failed", e);
|
| 527 |
+
alert("Failed to generate ZIP file.");
|
| 528 |
+
} finally {
|
| 529 |
+
setIsProcessing(false);
|
| 530 |
+
setStatusText("Ready");
|
| 531 |
+
}
|
| 532 |
+
};
|
| 533 |
+
|
| 534 |
+
const downloadSingleFace = (e, face, group, fIndex) => {
|
| 535 |
+
e.stopPropagation(); // Prevents the selection circle from toggling
|
| 536 |
+
const hasName = group.name && group.name.trim() !== '';
|
| 537 |
+
const filePrefix = hasName ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : 'face';
|
| 538 |
+
const fileName = hasName ? `${filePrefix}${fIndex + 1}.jpg` : `face_${fIndex + 1}.jpg`;
|
| 539 |
+
|
| 540 |
+
const a = document.createElement("a");
|
| 541 |
+
a.href = face.cropDataUrl;
|
| 542 |
+
a.download = fileName;
|
| 543 |
+
document.body.appendChild(a);
|
| 544 |
+
a.click();
|
| 545 |
+
document.body.removeChild(a);
|
| 546 |
+
};
|
| 547 |
+
|
| 548 |
+
return (
|
| 549 |
+
<div className="min-h-screen">
|
| 550 |
+
<header className="sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
| 551 |
+
<div className="flex items-center gap-4">
|
| 552 |
+
<LogoIcon size={42} className="shadow-lg drop-shadow-md" />
|
| 553 |
+
<div className="flex flex-col">
|
| 554 |
+
<h1 className="text-2xl font-extrabold tracking-[0.2em] text-white uppercase" style={{ fontFamily: "'Montserrat', sans-serif" }}>immager</h1>
|
| 555 |
+
<p className="text-[10px] sm:text-xs text-neutral-400 tracking-wider uppercase mt-0.5">crop faces easily in bulk</p>
|
| 556 |
+
</div>
|
| 557 |
+
</div>
|
| 558 |
+
|
| 559 |
+
<div className="flex items-center gap-4">
|
| 560 |
+
<button
|
| 561 |
+
onClick={() => setShowSettings(true)}
|
| 562 |
+
className="text-sm flex items-center gap-2 text-neutral-400 hover:text-white transition-colors"
|
| 563 |
+
>
|
| 564 |
+
<SettingsIcon size={16} />
|
| 565 |
+
Crop Settings
|
| 566 |
+
</button>
|
| 567 |
+
{faceGroups.length > 0 && (
|
| 568 |
+
<button
|
| 569 |
+
onClick={clearAll}
|
| 570 |
+
className="text-sm flex items-center gap-2 text-neutral-400 hover:text-white transition-colors"
|
| 571 |
+
>
|
| 572 |
+
<Trash2Icon size={16} />
|
| 573 |
+
Start Over
|
| 574 |
+
</button>
|
| 575 |
+
)}
|
| 576 |
+
</div>
|
| 577 |
+
</header>
|
| 578 |
+
|
| 579 |
+
<main className="max-w-7xl mx-auto p-6 pb-32">
|
| 580 |
+
{faceGroups.length === 0 && (
|
| 581 |
+
<div className="mt-12">
|
| 582 |
+
<div
|
| 583 |
+
onDragOver={(e) => e.preventDefault()}
|
| 584 |
+
onDrop={onDrop}
|
| 585 |
+
onClick={() => !isModelLoading && !isProcessing && fileInputRef.current.click()}
|
| 586 |
+
className={`
|
| 587 |
+
relative w-full max-w-2xl mx-auto flex flex-col items-center justify-center p-16
|
| 588 |
+
border-2 border-dashed rounded-3xl transition-all duration-200
|
| 589 |
+
${isModelLoading || isProcessing
|
| 590 |
+
? 'border-neutral-800 bg-neutral-900/30 cursor-not-allowed'
|
| 591 |
+
: 'border-neutral-700 bg-neutral-900/50 hover:bg-neutral-800 hover:border-indigo-500 cursor-pointer'}
|
| 592 |
+
`}
|
| 593 |
+
>
|
| 594 |
+
<input type="file" multiple accept="image/*" ref={fileInputRef} onChange={onFileChange} className="hidden" />
|
| 595 |
+
|
| 596 |
+
{(isModelLoading || isProcessing) ? (
|
| 597 |
+
<div className="flex flex-col items-center text-center">
|
| 598 |
+
<Loader2Icon size={48} className="text-indigo-500 animate-spin mb-6" />
|
| 599 |
+
<h3 className="text-xl font-medium text-white mb-2">{statusText}</h3>
|
| 600 |
+
{isProcessing && progress > 0 && (
|
| 601 |
+
<div className="w-full max-w-xs bg-neutral-800 rounded-full h-2 mt-4 overflow-hidden">
|
| 602 |
+
<div className="bg-indigo-500 h-2 rounded-full transition-all duration-300 ease-out" style={{ width: `${progress}%` }} />
|
| 603 |
+
</div>
|
| 604 |
+
)}
|
| 605 |
+
</div>
|
| 606 |
+
) : (
|
| 607 |
+
<div className="flex flex-col items-center text-center">
|
| 608 |
+
<div className="bg-neutral-800 p-4 rounded-2xl mb-6 shadow-inner">
|
| 609 |
+
<UploadCloudIcon size={40} className="text-indigo-400" />
|
| 610 |
+
</div>
|
| 611 |
+
<h3 className="text-2xl font-medium text-white mb-3">Upload Images</h3>
|
| 612 |
+
<p className="text-neutral-400 max-w-sm mb-6">Drag and drop your photos here, or click to browse. We'll find and group the faces.</p>
|
| 613 |
+
<button className="bg-white text-black px-6 py-2.5 rounded-full font-medium hover:bg-neutral-200 transition-colors">Select Images</button>
|
| 614 |
+
</div>
|
| 615 |
+
)}
|
| 616 |
+
</div>
|
| 617 |
+
|
| 618 |
+
{!isModelLoading && !isProcessing && (
|
| 619 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto mt-16 text-center">
|
| 620 |
+
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
|
| 621 |
+
<UsersIcon size={24} className="mx-auto text-indigo-400 mb-4" />
|
| 622 |
+
<h4 className="font-medium text-white mb-2">Smart Clustering</h4>
|
| 623 |
+
<p className="text-sm text-neutral-400">Groups faces of the same person together automatically.</p>
|
| 624 |
+
</div>
|
| 625 |
+
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
|
| 626 |
+
<ImageIcon size={24} className="mx-auto text-emerald-400 mb-4" />
|
| 627 |
+
<h4 className="font-medium text-white mb-2">Auto-Cropping</h4>
|
| 628 |
+
<p className="text-sm text-neutral-400">Extracts perfectly framed headshots ready for use.</p>
|
| 629 |
+
</div>
|
| 630 |
+
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
|
| 631 |
+
<CheckCircleIcon size={24} className="mx-auto text-amber-400 mb-4" />
|
| 632 |
+
<h4 className="font-medium text-white mb-2">100% Private</h4>
|
| 633 |
+
<p className="text-sm text-neutral-400">Everything runs entirely inside your browser. No server uploads.</p>
|
| 634 |
+
</div>
|
| 635 |
+
</div>
|
| 636 |
+
)}
|
| 637 |
+
</div>
|
| 638 |
+
)}
|
| 639 |
+
|
| 640 |
+
{faceGroups.length > 0 && (
|
| 641 |
+
<div className="animate-in">
|
| 642 |
+
|
| 643 |
+
{/* FLOATING OVERLAY to eliminate Layout Shift on drag start */}
|
| 644 |
+
{isDraggingFace && (
|
| 645 |
+
<div
|
| 646 |
+
className={`fixed top-24 left-1/2 -translate-x-1/2 z-50 w-full max-w-sm border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-6 shadow-2xl backdrop-blur-md transition-all ${dragOverGroupId === 'new-group' ? 'border-indigo-400 bg-indigo-500/30 scale-105' : 'border-neutral-500 bg-neutral-900/80 scale-100'}`}
|
| 647 |
+
onDragOver={(e) => handleDragOver(e, 'new-group')}
|
| 648 |
+
onDragLeave={handleDragLeave}
|
| 649 |
+
onDrop={(e) => handleDrop(e, 'new-group')}
|
| 650 |
+
>
|
| 651 |
+
<UsersIcon size={32} className="text-white mb-2" />
|
| 652 |
+
<p className="text-white font-medium text-center">Drop here to create a new person</p>
|
| 653 |
+
</div>
|
| 654 |
+
)}
|
| 655 |
+
|
| 656 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
| 657 |
+
<div>
|
| 658 |
+
<h2 className="text-2xl font-semibold tracking-tight">Found {faceGroups.length} People</h2>
|
| 659 |
+
<div className="text-sm text-neutral-400 mt-1">
|
| 660 |
+
Total unique faces: {faceGroups.reduce((acc, curr) => acc + curr.faces.length, 0)}
|
| 661 |
+
</div>
|
| 662 |
+
</div>
|
| 663 |
+
|
| 664 |
+
<div className="flex flex-col items-end gap-1 bg-neutral-900/80 p-3 rounded-xl border border-neutral-800 shadow-inner">
|
| 665 |
+
<label className="text-sm text-neutral-300 font-medium flex justify-between w-full">
|
| 666 |
+
<span>Grouping Tolerance</span>
|
| 667 |
+
<span className="text-indigo-400 font-bold">{matchThreshold.toFixed(2)}</span>
|
| 668 |
+
</label>
|
| 669 |
+
<input
|
| 670 |
+
type="range"
|
| 671 |
+
min="0.30" max="0.70" step="0.01"
|
| 672 |
+
value={matchThreshold}
|
| 673 |
+
onChange={(e) => {
|
| 674 |
+
const val = parseFloat(e.target.value);
|
| 675 |
+
setMatchThreshold(val);
|
| 676 |
+
clusterFaces(extractedFaces, val);
|
| 677 |
+
}}
|
| 678 |
+
className="w-48 md:w-64 accent-indigo-500 cursor-pointer"
|
| 679 |
+
title="Lower = stricter matching. Higher = looser matching."
|
| 680 |
+
/>
|
| 681 |
+
<span className="text-xs text-neutral-500">Slide right to merge similar people</span>
|
| 682 |
+
</div>
|
| 683 |
+
</div>
|
| 684 |
+
|
| 685 |
+
<div className="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6">
|
| 686 |
+
|
| 687 |
+
{faceGroups.map((group, groupIndex) => {
|
| 688 |
+
const allSelected = group.faces.every(f => selectedFaceIds.has(f.id));
|
| 689 |
+
const someSelected = !allSelected && group.faces.some(f => selectedFaceIds.has(f.id));
|
| 690 |
+
|
| 691 |
+
return (
|
| 692 |
+
<div
|
| 693 |
+
key={group.id}
|
| 694 |
+
className={`break-inside-avoid bg-neutral-900 rounded-2xl border transition-colors overflow-hidden group ${dragOverGroupId === group.id ? 'border-indigo-500 shadow-[0_0_15px_rgba(99,102,241,0.3)]' : 'border-neutral-800'}`}
|
| 695 |
+
onDragOver={(e) => handleDragOver(e, group.id)}
|
| 696 |
+
onDragLeave={handleDragLeave}
|
| 697 |
+
onDrop={(e) => handleDrop(e, group.id)}
|
| 698 |
+
>
|
| 699 |
+
<div className="px-5 py-4 flex items-center justify-between border-b border-neutral-800/50 bg-neutral-900/80">
|
| 700 |
+
<div className="flex items-center gap-3">
|
| 701 |
+
<div className="w-8 h-8 rounded-full overflow-hidden border border-neutral-700">
|
| 702 |
+
<img src={group.faces[0].cropDataUrl} className="w-full h-full object-cover pointer-events-none" alt={`Person ${groupIndex + 1}`} />
|
| 703 |
+
</div>
|
| 704 |
+
<div className="flex flex-col">
|
| 705 |
+
<input
|
| 706 |
+
type="text"
|
| 707 |
+
value={group.name}
|
| 708 |
+
placeholder={`Person ${groupIndex + 1}`}
|
| 709 |
+
onChange={(e) => updateGroupName(group.id, e.target.value)}
|
| 710 |
+
className="text-sm font-medium bg-transparent border-b border-transparent hover:border-neutral-600 focus:border-indigo-500 outline-none text-white w-32 placeholder:text-neutral-400 transition-colors"
|
| 711 |
+
/>
|
| 712 |
+
<div className="text-xs text-neutral-500">{group.faces.length} shots</div>
|
| 713 |
+
</div>
|
| 714 |
+
</div>
|
| 715 |
+
<button onClick={() => toggleGroup(group)} className="text-neutral-400 hover:text-white transition-colors p-1" title={allSelected ? "Deselect All" : "Select All"}>
|
| 716 |
+
{allSelected ? <CheckSquareIcon size={20} className="text-indigo-500" />
|
| 717 |
+
: someSelected ? <CheckSquareIcon size={20} className="text-indigo-500 opacity-50" />
|
| 718 |
+
: <SquareIcon size={20} />}
|
| 719 |
+
</button>
|
| 720 |
+
</div>
|
| 721 |
+
|
| 722 |
+
<div className="p-4 grid grid-cols-3 gap-3">
|
| 723 |
+
{group.faces.map((face, fIndex) => {
|
| 724 |
+
const isSelected = selectedFaceIds.has(face.id);
|
| 725 |
+
return (
|
| 726 |
+
<div
|
| 727 |
+
key={face.id}
|
| 728 |
+
draggable={true}
|
| 729 |
+
onDragStart={(e) => handleDragStart(e, face.id, group.id)}
|
| 730 |
+
onDragEnd={handleDragEnd}
|
| 731 |
+
onClick={() => toggleFace(face.id)}
|
| 732 |
+
className={`relative aspect-square rounded-xl overflow-hidden cursor-grab active:cursor-grabbing group/item transition-all duration-200 border-2 ${isSelected ? 'border-indigo-500 scale-95' : 'border-transparent hover:border-neutral-600'}`}
|
| 733 |
+
>
|
| 734 |
+
<img src={face.cropDataUrl} alt="Crop" draggable="false" className="w-full h-full object-cover select-none pointer-events-none" loading="lazy" />
|
| 735 |
+
|
| 736 |
+
<div className={`absolute top-2 right-2 opacity-0 group-hover/item:opacity-100 transition-opacity z-10 flex gap-1.5`}>
|
| 737 |
+
<button onClick={(e) => downloadSingleFace(e, face, group, fIndex)} className="p-1.5 bg-neutral-900/80 hover:bg-emerald-500 rounded-lg text-white backdrop-blur border border-neutral-700 transition-colors shadow-sm" title="Download Image">
|
| 738 |
+
<DownloadIcon size={14} />
|
| 739 |
+
</button>
|
| 740 |
+
<button onClick={(e) => { e.stopPropagation(); openEditor(face, groupIndex, fIndex); }} className="p-1.5 bg-neutral-900/80 hover:bg-indigo-500 rounded-lg text-white backdrop-blur border border-neutral-700 transition-colors shadow-sm" title="Manual Crop">
|
| 741 |
+
<EditIcon size={14} />
|
| 742 |
+
</button>
|
| 743 |
+
</div>
|
| 744 |
+
|
| 745 |
+
<div className={`absolute inset-0 pointer-events-none flex items-center justify-center transition-opacity ${isSelected ? 'bg-indigo-500/20 opacity-100' : 'bg-black/40 opacity-0 group-hover/item:opacity-100'}`}>
|
| 746 |
+
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center ${isSelected ? 'bg-indigo-500 border-indigo-500' : 'border-white/70'}`}>
|
| 747 |
+
{isSelected && <CheckCircleIcon size={14} className="text-white" />}
|
| 748 |
+
</div>
|
| 749 |
+
</div>
|
| 750 |
+
</div>
|
| 751 |
+
);
|
| 752 |
+
})}
|
| 753 |
+
</div>
|
| 754 |
+
</div>
|
| 755 |
+
);
|
| 756 |
+
})}
|
| 757 |
+
</div>
|
| 758 |
+
</div>
|
| 759 |
+
)}
|
| 760 |
+
</main>
|
| 761 |
+
|
| 762 |
+
{faceGroups.length > 0 && (
|
| 763 |
+
<div className={`fixed bottom-8 left-1/2 -translate-x-1/2 z-40 transition-all duration-300 ${selectedFaceIds.size > 0 ? 'translate-y-0 opacity-100' : 'translate-y-10 opacity-0 pointer-events-none'}`}>
|
| 764 |
+
<div className="bg-neutral-900 border border-neutral-800 shadow-2xl rounded-full p-2 pl-6 pr-3 flex items-center gap-6">
|
| 765 |
+
<div className="font-medium text-sm">
|
| 766 |
+
<span className="text-indigo-400 font-bold">{selectedFaceIds.size}</span> faces selected
|
| 767 |
+
</div>
|
| 768 |
+
<button onClick={downloadSelected} disabled={isProcessing} className={`flex items-center gap-2 bg-indigo-600 text-white px-5 py-2.5 rounded-full text-sm font-medium hover:bg-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}>
|
| 769 |
+
{isProcessing ? <Loader2Icon size={18} className="animate-spin" /> : <DownloadIcon size={18} />}
|
| 770 |
+
{isProcessing ? 'Creating ZIP...' : 'Download'}
|
| 771 |
+
</button>
|
| 772 |
+
</div>
|
| 773 |
+
</div>
|
| 774 |
+
)}
|
| 775 |
+
|
| 776 |
+
{showSettings && (
|
| 777 |
+
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
|
| 778 |
+
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 w-full max-w-md shadow-2xl animate-in">
|
| 779 |
+
<div className="flex items-center justify-between mb-6">
|
| 780 |
+
<h3 className="text-lg font-semibold text-white">Smart Crop Settings</h3>
|
| 781 |
+
<button onClick={() => setShowSettings(false)} className="text-neutral-400 hover:text-white"><XIcon size={20}/></button>
|
| 782 |
+
</div>
|
| 783 |
+
<div className="space-y-4">
|
| 784 |
+
<div>
|
| 785 |
+
<label className="block text-sm text-neutral-400 mb-2">Padding Style</label>
|
| 786 |
+
<select
|
| 787 |
+
disabled={isProcessing}
|
| 788 |
+
value={cropSettings.padding}
|
| 789 |
+
onChange={(e) => applyGlobalCropSettings({...cropSettings, padding: parseFloat(e.target.value)})}
|
| 790 |
+
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-2.5 text-white outline-none focus:border-indigo-500 disabled:opacity-50"
|
| 791 |
+
>
|
| 792 |
+
<option value="0.05">Very Tight (Exclude others)</option>
|
| 793 |
+
<option value="0.15">Tight</option>
|
| 794 |
+
<option value="0.3">Normal</option>
|
| 795 |
+
<option value="0.5">Wide</option>
|
| 796 |
+
</select>
|
| 797 |
+
</div>
|
| 798 |
+
<div>
|
| 799 |
+
<label className="block text-sm text-neutral-400 mb-2">Shape</label>
|
| 800 |
+
<select
|
| 801 |
+
disabled={isProcessing}
|
| 802 |
+
value={cropSettings.shape}
|
| 803 |
+
onChange={(e) => applyGlobalCropSettings({...cropSettings, shape: e.target.value})}
|
| 804 |
+
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-2.5 text-white outline-none focus:border-indigo-500 disabled:opacity-50"
|
| 805 |
+
>
|
| 806 |
+
<option value="square">Square</option>
|
| 807 |
+
<option value="original">Original Aspect</option>
|
| 808 |
+
</select>
|
| 809 |
+
</div>
|
| 810 |
+
<p className="text-xs text-neutral-500 mt-4 leading-relaxed">Changes apply instantly to all currently extracted faces.</p>
|
| 811 |
+
<button onClick={() => setShowSettings(false)} disabled={isProcessing} className="w-full mt-6 bg-indigo-600 hover:bg-indigo-500 text-white py-2.5 rounded-lg font-medium transition-colors disabled:opacity-50">Done</button>
|
| 812 |
+
</div>
|
| 813 |
+
</div>
|
| 814 |
+
</div>
|
| 815 |
+
)}
|
| 816 |
+
|
| 817 |
+
{editingFace && (
|
| 818 |
+
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
|
| 819 |
+
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 w-full max-w-lg flex flex-col items-center shadow-2xl animate-in">
|
| 820 |
+
<div className="w-full flex items-center justify-between mb-4">
|
| 821 |
+
<h3 className="text-lg font-semibold text-white">Manual Crop Adjust (Drag to Pan)</h3>
|
| 822 |
+
<button onClick={() => setEditingFace(null)} className="text-neutral-400 hover:text-white"><XIcon size={20}/></button>
|
| 823 |
+
</div>
|
| 824 |
+
|
| 825 |
+
<div
|
| 826 |
+
className={`relative w-64 h-64 bg-neutral-800 rounded-xl overflow-hidden mb-6 border border-neutral-700 flex items-center justify-center shadow-inner ${dragState.isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
| 827 |
+
onMouseDown={(e) => {
|
| 828 |
+
setDragState({
|
| 829 |
+
isDragging: true,
|
| 830 |
+
startX: e.clientX,
|
| 831 |
+
startY: e.clientY,
|
| 832 |
+
initialOffsetX: editingFace.manualOffsets.x,
|
| 833 |
+
initialOffsetY: editingFace.manualOffsets.y
|
| 834 |
+
});
|
| 835 |
+
}}
|
| 836 |
+
onMouseMove={(e) => {
|
| 837 |
+
if (!dragState.isDragging || !editingFace) return;
|
| 838 |
+
const dx = e.clientX - dragState.startX;
|
| 839 |
+
const dy = e.clientY - dragState.startY;
|
| 840 |
+
|
| 841 |
+
// Scale drag distance based on image zoom and box size to feel natural
|
| 842 |
+
const scale = Math.max(1, editingFace.originalBox.width / 128) / editingFace.manualOffsets.zoom;
|
| 843 |
+
|
| 844 |
+
updateManualCrop({
|
| 845 |
+
x: dragState.initialOffsetX - (dx * scale),
|
| 846 |
+
y: dragState.initialOffsetY - (dy * scale)
|
| 847 |
+
});
|
| 848 |
+
}}
|
| 849 |
+
onMouseUp={() => setDragState(prev => ({ ...prev, isDragging: false }))}
|
| 850 |
+
onMouseLeave={() => setDragState(prev => ({ ...prev, isDragging: false }))}
|
| 851 |
+
>
|
| 852 |
+
<img
|
| 853 |
+
src={editingFace.previewUrl}
|
| 854 |
+
style={{ imageRendering: editingFace.manualOffsets.resolution !== 'auto' && parseInt(editingFace.manualOffsets.resolution) < 150 ? 'pixelated' : 'auto' }}
|
| 855 |
+
className="max-w-full max-h-full object-contain pointer-events-none select-none"
|
| 856 |
+
alt="Preview"
|
| 857 |
+
draggable="false"
|
| 858 |
+
/>
|
| 859 |
+
</div>
|
| 860 |
+
|
| 861 |
+
<div className="w-full space-y-5">
|
| 862 |
+
<div>
|
| 863 |
+
<label className="flex justify-between text-sm text-neutral-400 mb-2">
|
| 864 |
+
<span>Export Resolution</span>
|
| 865 |
+
<span>{editingFace.manualOffsets.resolution === 'auto' ? 'Auto' : `${editingFace.manualOffsets.resolution}x${editingFace.manualOffsets.resolution}`}</span>
|
| 866 |
+
</label>
|
| 867 |
+
<select
|
| 868 |
+
value={editingFace.manualOffsets.resolution}
|
| 869 |
+
onChange={(e) => updateManualCrop({ resolution: e.target.value })}
|
| 870 |
+
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-2.5 text-white outline-none focus:border-indigo-500"
|
| 871 |
+
>
|
| 872 |
+
<option value="auto">Auto (Original Detected Size)</option>
|
| 873 |
+
<option value="56">56 x 56</option>
|
| 874 |
+
<option value="128">128 x 128</option>
|
| 875 |
+
<option value="256">256 x 256</option>
|
| 876 |
+
<option value="512">512 x 512</option>
|
| 877 |
+
</select>
|
| 878 |
+
</div>
|
| 879 |
+
<div>
|
| 880 |
+
<label className="flex justify-between text-sm text-neutral-400 mb-2">
|
| 881 |
+
<span>Zoom</span>
|
| 882 |
+
<span>{editingFace.manualOffsets.zoom.toFixed(1)}x</span>
|
| 883 |
+
</label>
|
| 884 |
+
<input type="range" min="0.5" max="2.5" step="0.1" value={editingFace.manualOffsets.zoom} onChange={(e) => updateManualCrop({ zoom: parseFloat(e.target.value) })} className="w-full accent-indigo-500" />
|
| 885 |
+
</div>
|
| 886 |
+
</div>
|
| 887 |
+
|
| 888 |
+
<div className="w-full flex gap-3 mt-8">
|
| 889 |
+
<button onClick={() => setEditingFace(null)} className="flex-1 py-2.5 rounded-lg border border-neutral-700 text-white hover:bg-neutral-800 transition-colors">Cancel</button>
|
| 890 |
+
<button onClick={saveManualCrop} className="flex-1 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors shadow-lg">Apply Crop</button>
|
| 891 |
+
</div>
|
| 892 |
+
</div>
|
| 893 |
+
</div>
|
| 894 |
+
)}
|
| 895 |
+
</div>
|
| 896 |
+
);
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
// Mount the application
|
| 900 |
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
| 901 |
+
root.render(<FaceExtractApp />);
|
| 902 |
+
</script>
|
| 903 |
+
</body>
|
| 904 |
+
</html>
|