trysem commited on
Commit
ca66302
·
verified ·
1 Parent(s): 7c2dfeb

Create immager

Browse files
Files changed (1) hide show
  1. immager +904 -0
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>