splug / immager
trysem's picture
Create immager
ca66302 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>immager - crop faces easily in bulk</title>
<!-- Tailwind CSS for styling -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Google Fonts for cinematic logo -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;800&display=swap" rel="stylesheet">
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<!-- Babel for parsing JSX in the browser -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
/* Custom animations */
@keyframes spin { 100% { transform: rotate(360deg); } }
.animate-spin { animation: spin 1s linear infinite; }
@keyframes slideIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.animate-in { animation: slideIn 0.5s ease-out forwards; }
</style>
</head>
<body class="bg-neutral-950 text-neutral-100 font-sans selection:bg-indigo-500/30">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useRef } = React;
// --- Inline Icons (Replaces external icon libraries for offline use) ---
const LogoIcon = ({size=24, className=""}) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 512 512" className={className}>
<rect width="512" height="512" rx="120" fill="#12C369"/>
<circle cx="380" cy="130" r="55" fill="#ffffff"/>
<path d="M310 280 L440 430 L180 430 Z" fill="#91E6B3" stroke="#91E6B3" strokeWidth="30" strokeLinejoin="round"/>
<path d="M220 220 L340 430 L100 430 Z" fill="#ffffff" stroke="#ffffff" strokeWidth="40" strokeLinejoin="round"/>
</svg>
);
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>);
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>);
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>);
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>);
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>);
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>);
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>);
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>);
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>);
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>);
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>);
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>);
// --- Main Application Component ---
function FaceExtractApp() {
const [isModelLoading, setIsModelLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState('Initializing Engine...');
const [faceGroups, setFaceGroups] = useState([]);
const [selectedFaceIds, setSelectedFaceIds] = useState(new Set());
const fileInputRef = useRef(null);
// New states for real-time reclustering
const [extractedFaces, setExtractedFaces] = useState([]);
const [matchThreshold, setMatchThreshold] = useState(0.50);
// New Crop Settings States (Updated for strict, very tight cropping by default)
const [cropSettings, setCropSettings] = useState({ padding: 0.05, topPadding: 0.2, shape: 'square' });
const [showSettings, setShowSettings] = useState(false);
const [editingFace, setEditingFace] = useState(null);
const editorImgRef = useRef(null);
// Drag state for manual crop
const [dragState, setDragState] = useState({ isDragging: false, startX: 0, startY: 0, initialOffsetX: 0, initialOffsetY: 0 });
// Drag and drop states for moving faces between groups
const [dragOverGroupId, setDragOverGroupId] = useState(null);
const [isDraggingFace, setIsDraggingFace] = useState(false);
const draggedFaceRef = useRef(null); // Foolproof fallback memory
// Load external scripts dynamically (face-api.js and jszip)
useEffect(() => {
const loadScripts = async () => {
const loadScript = (src) => new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve();
const s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = resolve;
s.onerror = reject;
document.body.appendChild(s);
});
try {
await loadScript('https://cdn.jsdelivr.net/npm/@vladmandic/face-api/dist/face-api.js');
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js');
loadAIModels();
} catch (e) {
console.error("Failed to load scripts", e);
setStatusText("Failed to load core libraries. Please check your internet connection.");
}
};
loadScripts();
}, []);
const loadAIModels = async () => {
try {
setStatusText("Loading AI Models (~5MB)...");
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/';
await window.faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL);
await window.faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL);
await window.faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL);
setIsModelLoading(false);
setStatusText("Ready");
} catch (error) {
console.error(error);
setStatusText("Error loading AI models.");
}
};
const generateCrop = (img, box, settings, manualOffsets = { x: 0, y: 0, zoom: 1, resolution: 'auto' }) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let padX = box.width * settings.padding;
let padY = box.height * settings.padding;
let tw = box.width + (padX * 2);
let th = box.height + padY + (box.height * settings.topPadding);
if (settings.shape === 'square') {
const size = Math.max(tw, th);
tw = size;
th = size;
}
// Apply manual zoom (scale)
tw /= manualOffsets.zoom;
th /= manualOffsets.zoom;
// Base center point mapping
let cx = box.x + box.width / 2;
let cy = box.y + box.height / 2 - (box.height * settings.topPadding / 2) + (padY / 2);
// Apply manual panning
cx += manualOffsets.x;
cy += manualOffsets.y;
const cropX = Math.max(0, cx - tw / 2);
const cropY = Math.max(0, cy - th / 2);
const cropW = Math.min(img.width - cropX, tw);
const cropH = Math.min(img.height - cropY, th);
// Output Resolution Logic
let targetW = cropW;
let targetH = cropH;
if (manualOffsets.resolution && manualOffsets.resolution !== 'auto') {
const res = parseInt(manualOffsets.resolution, 10);
targetW = res;
// Maintain proportional aspect ratio if "Original Aspect" is selected in global settings
targetH = settings.shape === 'original' ? Math.round(res * (cropH / cropW)) : res;
}
canvas.width = targetW;
canvas.height = targetH;
ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, targetW, targetH);
return canvas.toDataURL('image/jpeg', 0.9);
};
const openEditor = async (face, groupIndex, faceIndex) => {
const img = new Image();
img.src = face.sourceUrl;
await new Promise(r => img.onload = r);
editorImgRef.current = img;
setEditingFace({
...face,
groupIndex,
faceIndex,
manualOffsets: face.manualOffsets || { x: 0, y: 0, zoom: 1, resolution: 'auto' },
previewUrl: face.cropDataUrl
});
setDragState({ isDragging: false, startX: 0, startY: 0, initialOffsetX: 0, initialOffsetY: 0 });
};
const updateManualCrop = (updates) => {
if (!editingFace || !editorImgRef.current) return;
const newOffsets = { ...editingFace.manualOffsets, ...updates };
const newPreview = generateCrop(editorImgRef.current, editingFace.originalBox, cropSettings, newOffsets);
setEditingFace(prev => ({
...prev,
manualOffsets: newOffsets,
previewUrl: newPreview
}));
};
const saveManualCrop = () => {
const newGroups = [...faceGroups];
newGroups[editingFace.groupIndex].faces[editingFace.faceIndex].cropDataUrl = editingFace.previewUrl;
newGroups[editingFace.groupIndex].faces[editingFace.faceIndex].manualOffsets = editingFace.manualOffsets;
setFaceGroups(newGroups);
setEditingFace(null);
};
const updateGroupName = (groupId, newName) => {
setFaceGroups(prevGroups => prevGroups.map(g =>
g.id === groupId ? { ...g, name: newName } : g
));
};
// --- ROCK SOLID DRAG AND DROP ---
const handleDragStart = (e, faceId, sourceGroupId) => {
draggedFaceRef.current = { faceId, sourceGroupId };
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', faceId);
// DEFER state update so layout shift doesn't instantly cancel the drag event
setTimeout(() => {
setIsDraggingFace(true);
}, 0);
};
const handleDragEnd = (e) => {
setIsDraggingFace(false);
setDragOverGroupId(null);
draggedFaceRef.current = null;
};
const handleDragOver = (e, targetGroupId) => {
e.preventDefault(); // Crucial to allow dropping
e.dataTransfer.dropEffect = 'move';
if (dragOverGroupId !== targetGroupId) {
setDragOverGroupId(targetGroupId);
}
};
const handleDragLeave = (e) => {
e.preventDefault();
// Leave intentionally empty! Reacting to dragLeave causes extreme flickering
// when dragging over child elements, which aborts the drag operation.
};
const handleDrop = (e, targetGroupId) => {
e.preventDefault();
setIsDraggingFace(false);
setDragOverGroupId(null);
const data = draggedFaceRef.current;
if (!data) return;
const { faceId, sourceGroupId } = data;
if (sourceGroupId === targetGroupId) return;
// DEFER DOM mutation until the native drag sequence resolves fully
setTimeout(() => {
setFaceGroups(prevGroups => {
let movedFace = null;
// 1. Remove face immutably
let updatedGroups = prevGroups.map(group => {
if (group.id === sourceGroupId) {
const faceIndex = group.faces.findIndex(f => f.id === faceId);
if (faceIndex > -1) {
movedFace = group.faces[faceIndex];
return { ...group, faces: group.faces.filter(f => f.id !== faceId) };
}
}
return group;
});
if (!movedFace) return prevGroups;
// 2. Add face immutably
if (targetGroupId === 'new-group') {
updatedGroups.unshift({
id: `group-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: '',
baseDescriptor: movedFace.descriptor,
faces: [movedFace]
});
} else {
updatedGroups = updatedGroups.map(group => {
if (group.id === targetGroupId) {
return { ...group, faces: [...group.faces, movedFace] };
}
return group;
});
}
// 3. Clean up
return updatedGroups.filter(g => g.faces.length > 0);
});
}, 0);
};
const applyGlobalCropSettings = async (newSettings) => {
setCropSettings(newSettings);
if (extractedFaces.length === 0) return;
setIsProcessing(true);
setStatusText("Applying new crop settings...");
// Small delay to let React render the loading state before heavy calculation
await new Promise(resolve => setTimeout(resolve, 50));
try {
const imageCache = {};
const updatedFaces = [];
for (let i = 0; i < extractedFaces.length; i++) {
const face = extractedFaces[i];
let img = imageCache[face.sourceUrl];
if (!img) {
img = new Image();
img.src = face.sourceUrl;
await new Promise(r => img.onload = r);
imageCache[face.sourceUrl] = img;
}
const newCropDataUrl = generateCrop(img, face.originalBox, newSettings, face.manualOffsets || { x: 0, y: 0, zoom: 1, resolution: 'auto' });
updatedFaces.push({ ...face, cropDataUrl: newCropDataUrl });
}
setExtractedFaces(updatedFaces);
// Update faceGroups to preserve names, groupings, and selections
setFaceGroups(prevGroups => prevGroups.map(group => ({
...group,
faces: group.faces.map(gFace => {
const updatedFace = updatedFaces.find(uf => uf.id === gFace.id);
return updatedFace ? { ...gFace, cropDataUrl: updatedFace.cropDataUrl } : gFace;
})
})));
} catch (error) {
console.error("Error applying crop settings:", error);
} finally {
setIsProcessing(false);
setStatusText("Done");
}
};
const clusterFaces = useCallback((facesToCluster, threshold) => {
const groups = [];
facesToCluster.forEach(face => {
let foundGroup = false;
for (let group of groups) {
const distance = window.faceapi.euclideanDistance(group.baseDescriptor, face.descriptor);
if (distance < threshold) {
group.faces.push(face);
foundGroup = true;
break;
}
}
if (!foundGroup) {
groups.push({
id: `group-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: '',
baseDescriptor: face.descriptor,
faces: [face]
});
}
});
// Sort groups by most frequent faces
groups.sort((a, b) => b.faces.length - a.faces.length);
setFaceGroups(groups);
}, []);
const processImages = async (files) => {
if (!files || files.length === 0) return;
setIsProcessing(true);
setFaceGroups([]);
setExtractedFaces([]);
setSelectedFaceIds(new Set());
const allExtractedFaces = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith('image/')) continue;
setStatusText(`Scanning image ${i + 1} of ${files.length}...`);
setProgress(((i) / files.length) * 100);
try {
const img = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const image = new Image();
image.src = e.target.result;
image.onload = () => resolve(image);
image.onerror = reject;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
const detections = await window.faceapi.detectAllFaces(img)
.withFaceLandmarks()
.withFaceDescriptors();
const sourceUrl = URL.createObjectURL(file);
detections.forEach((det, idx) => {
const cropDataUrl = generateCrop(img, det.detection.box, cropSettings);
allExtractedFaces.push({
id: `face-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sourceFile: file.name,
sourceUrl: sourceUrl,
originalBox: det.detection.box,
cropDataUrl,
descriptor: det.descriptor
});
});
} catch (err) {
console.error(`Error processing ${file.name}`, err);
}
}
setStatusText("Organizing faces by person...");
// Save to state and run initial clustering
setExtractedFaces(allExtractedFaces);
clusterFaces(allExtractedFaces, matchThreshold);
setIsProcessing(false);
setProgress(100);
setStatusText("Done");
};
const onDrop = useCallback((e) => {
e.preventDefault();
if (isModelLoading || isProcessing) return;
const files = Array.from(e.dataTransfer.files);
processImages(files);
}, [isModelLoading, isProcessing]);
const onFileChange = (e) => {
if (e.target.files && e.target.files.length > 0) {
processImages(Array.from(e.target.files));
}
};
const toggleFace = (id) => {
const next = new Set(selectedFaceIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
setSelectedFaceIds(next);
};
const toggleGroup = (group) => {
const next = new Set(selectedFaceIds);
const allSelected = group.faces.every(f => next.has(f.id));
group.faces.forEach(f => {
if (allSelected) {
next.delete(f.id);
} else {
next.add(f.id);
}
});
setSelectedFaceIds(next);
};
const clearAll = () => {
setFaceGroups([]);
setExtractedFaces([]);
setSelectedFaceIds(new Set());
setProgress(0);
};
const downloadSelected = async () => {
if (selectedFaceIds.size === 0) return;
setIsProcessing(true);
setStatusText("Generating ZIP archive...");
try {
const zip = new window.JSZip();
let totalExported = 0;
faceGroups.forEach((group, gIndex) => {
// Determine folder and file names based on user input
const hasName = group.name && group.name.trim() !== '';
const folderName = hasName ? group.name.trim() : `Person_${gIndex + 1}`;
const filePrefix = hasName ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : 'face';
const selectedInGroup = group.faces.filter(f => selectedFaceIds.has(f.id));
if (selectedInGroup.length > 0) {
const folder = zip.folder(folderName);
selectedInGroup.forEach((face, fIndex) => {
const base64Data = face.cropDataUrl.split(',')[1];
const fileName = hasName ? `${filePrefix}${fIndex + 1}.jpg` : `face_${fIndex + 1}.jpg`;
folder.file(fileName, base64Data, {base64: true});
totalExported++;
});
}
});
const content = await zip.generateAsync({type: "blob"});
const url = URL.createObjectURL(content);
const a = document.createElement("a");
a.href = url;
a.download = `Extracted_Faces_${totalExported}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error("ZIP Generation Failed", e);
alert("Failed to generate ZIP file.");
} finally {
setIsProcessing(false);
setStatusText("Ready");
}
};
const downloadSingleFace = (e, face, group, fIndex) => {
e.stopPropagation(); // Prevents the selection circle from toggling
const hasName = group.name && group.name.trim() !== '';
const filePrefix = hasName ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : 'face';
const fileName = hasName ? `${filePrefix}${fIndex + 1}.jpg` : `face_${fIndex + 1}.jpg`;
const a = document.createElement("a");
a.href = face.cropDataUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
return (
<div className="min-h-screen">
<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">
<div className="flex items-center gap-4">
<LogoIcon size={42} className="shadow-lg drop-shadow-md" />
<div className="flex flex-col">
<h1 className="text-2xl font-extrabold tracking-[0.2em] text-white uppercase" style={{ fontFamily: "'Montserrat', sans-serif" }}>immager</h1>
<p className="text-[10px] sm:text-xs text-neutral-400 tracking-wider uppercase mt-0.5">crop faces easily in bulk</p>
</div>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => setShowSettings(true)}
className="text-sm flex items-center gap-2 text-neutral-400 hover:text-white transition-colors"
>
<SettingsIcon size={16} />
Crop Settings
</button>
{faceGroups.length > 0 && (
<button
onClick={clearAll}
className="text-sm flex items-center gap-2 text-neutral-400 hover:text-white transition-colors"
>
<Trash2Icon size={16} />
Start Over
</button>
)}
</div>
</header>
<main className="max-w-7xl mx-auto p-6 pb-32">
{faceGroups.length === 0 && (
<div className="mt-12">
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
onClick={() => !isModelLoading && !isProcessing && fileInputRef.current.click()}
className={`
relative w-full max-w-2xl mx-auto flex flex-col items-center justify-center p-16
border-2 border-dashed rounded-3xl transition-all duration-200
${isModelLoading || isProcessing
? 'border-neutral-800 bg-neutral-900/30 cursor-not-allowed'
: 'border-neutral-700 bg-neutral-900/50 hover:bg-neutral-800 hover:border-indigo-500 cursor-pointer'}
`}
>
<input type="file" multiple accept="image/*" ref={fileInputRef} onChange={onFileChange} className="hidden" />
{(isModelLoading || isProcessing) ? (
<div className="flex flex-col items-center text-center">
<Loader2Icon size={48} className="text-indigo-500 animate-spin mb-6" />
<h3 className="text-xl font-medium text-white mb-2">{statusText}</h3>
{isProcessing && progress > 0 && (
<div className="w-full max-w-xs bg-neutral-800 rounded-full h-2 mt-4 overflow-hidden">
<div className="bg-indigo-500 h-2 rounded-full transition-all duration-300 ease-out" style={{ width: `${progress}%` }} />
</div>
)}
</div>
) : (
<div className="flex flex-col items-center text-center">
<div className="bg-neutral-800 p-4 rounded-2xl mb-6 shadow-inner">
<UploadCloudIcon size={40} className="text-indigo-400" />
</div>
<h3 className="text-2xl font-medium text-white mb-3">Upload Images</h3>
<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>
<button className="bg-white text-black px-6 py-2.5 rounded-full font-medium hover:bg-neutral-200 transition-colors">Select Images</button>
</div>
)}
</div>
{!isModelLoading && !isProcessing && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto mt-16 text-center">
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
<UsersIcon size={24} className="mx-auto text-indigo-400 mb-4" />
<h4 className="font-medium text-white mb-2">Smart Clustering</h4>
<p className="text-sm text-neutral-400">Groups faces of the same person together automatically.</p>
</div>
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
<ImageIcon size={24} className="mx-auto text-emerald-400 mb-4" />
<h4 className="font-medium text-white mb-2">Auto-Cropping</h4>
<p className="text-sm text-neutral-400">Extracts perfectly framed headshots ready for use.</p>
</div>
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
<CheckCircleIcon size={24} className="mx-auto text-amber-400 mb-4" />
<h4 className="font-medium text-white mb-2">100% Private</h4>
<p className="text-sm text-neutral-400">Everything runs entirely inside your browser. No server uploads.</p>
</div>
</div>
)}
</div>
)}
{faceGroups.length > 0 && (
<div className="animate-in">
{/* FLOATING OVERLAY to eliminate Layout Shift on drag start */}
{isDraggingFace && (
<div
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'}`}
onDragOver={(e) => handleDragOver(e, 'new-group')}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, 'new-group')}
>
<UsersIcon size={32} className="text-white mb-2" />
<p className="text-white font-medium text-center">Drop here to create a new person</p>
</div>
)}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Found {faceGroups.length} People</h2>
<div className="text-sm text-neutral-400 mt-1">
Total unique faces: {faceGroups.reduce((acc, curr) => acc + curr.faces.length, 0)}
</div>
</div>
<div className="flex flex-col items-end gap-1 bg-neutral-900/80 p-3 rounded-xl border border-neutral-800 shadow-inner">
<label className="text-sm text-neutral-300 font-medium flex justify-between w-full">
<span>Grouping Tolerance</span>
<span className="text-indigo-400 font-bold">{matchThreshold.toFixed(2)}</span>
</label>
<input
type="range"
min="0.30" max="0.70" step="0.01"
value={matchThreshold}
onChange={(e) => {
const val = parseFloat(e.target.value);
setMatchThreshold(val);
clusterFaces(extractedFaces, val);
}}
className="w-48 md:w-64 accent-indigo-500 cursor-pointer"
title="Lower = stricter matching. Higher = looser matching."
/>
<span className="text-xs text-neutral-500">Slide right to merge similar people</span>
</div>
</div>
<div className="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6">
{faceGroups.map((group, groupIndex) => {
const allSelected = group.faces.every(f => selectedFaceIds.has(f.id));
const someSelected = !allSelected && group.faces.some(f => selectedFaceIds.has(f.id));
return (
<div
key={group.id}
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'}`}
onDragOver={(e) => handleDragOver(e, group.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, group.id)}
>
<div className="px-5 py-4 flex items-center justify-between border-b border-neutral-800/50 bg-neutral-900/80">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full overflow-hidden border border-neutral-700">
<img src={group.faces[0].cropDataUrl} className="w-full h-full object-cover pointer-events-none" alt={`Person ${groupIndex + 1}`} />
</div>
<div className="flex flex-col">
<input
type="text"
value={group.name}
placeholder={`Person ${groupIndex + 1}`}
onChange={(e) => updateGroupName(group.id, e.target.value)}
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"
/>
<div className="text-xs text-neutral-500">{group.faces.length} shots</div>
</div>
</div>
<button onClick={() => toggleGroup(group)} className="text-neutral-400 hover:text-white transition-colors p-1" title={allSelected ? "Deselect All" : "Select All"}>
{allSelected ? <CheckSquareIcon size={20} className="text-indigo-500" />
: someSelected ? <CheckSquareIcon size={20} className="text-indigo-500 opacity-50" />
: <SquareIcon size={20} />}
</button>
</div>
<div className="p-4 grid grid-cols-3 gap-3">
{group.faces.map((face, fIndex) => {
const isSelected = selectedFaceIds.has(face.id);
return (
<div
key={face.id}
draggable={true}
onDragStart={(e) => handleDragStart(e, face.id, group.id)}
onDragEnd={handleDragEnd}
onClick={() => toggleFace(face.id)}
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'}`}
>
<img src={face.cropDataUrl} alt="Crop" draggable="false" className="w-full h-full object-cover select-none pointer-events-none" loading="lazy" />
<div className={`absolute top-2 right-2 opacity-0 group-hover/item:opacity-100 transition-opacity z-10 flex gap-1.5`}>
<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">
<DownloadIcon size={14} />
</button>
<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">
<EditIcon size={14} />
</button>
</div>
<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'}`}>
<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'}`}>
{isSelected && <CheckCircleIcon size={14} className="text-white" />}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
</main>
{faceGroups.length > 0 && (
<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'}`}>
<div className="bg-neutral-900 border border-neutral-800 shadow-2xl rounded-full p-2 pl-6 pr-3 flex items-center gap-6">
<div className="font-medium text-sm">
<span className="text-indigo-400 font-bold">{selectedFaceIds.size}</span> faces selected
</div>
<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`}>
{isProcessing ? <Loader2Icon size={18} className="animate-spin" /> : <DownloadIcon size={18} />}
{isProcessing ? 'Creating ZIP...' : 'Download'}
</button>
</div>
</div>
)}
{showSettings && (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 w-full max-w-md shadow-2xl animate-in">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white">Smart Crop Settings</h3>
<button onClick={() => setShowSettings(false)} className="text-neutral-400 hover:text-white"><XIcon size={20}/></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-neutral-400 mb-2">Padding Style</label>
<select
disabled={isProcessing}
value={cropSettings.padding}
onChange={(e) => applyGlobalCropSettings({...cropSettings, padding: parseFloat(e.target.value)})}
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"
>
<option value="0.05">Very Tight (Exclude others)</option>
<option value="0.15">Tight</option>
<option value="0.3">Normal</option>
<option value="0.5">Wide</option>
</select>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Shape</label>
<select
disabled={isProcessing}
value={cropSettings.shape}
onChange={(e) => applyGlobalCropSettings({...cropSettings, shape: e.target.value})}
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"
>
<option value="square">Square</option>
<option value="original">Original Aspect</option>
</select>
</div>
<p className="text-xs text-neutral-500 mt-4 leading-relaxed">Changes apply instantly to all currently extracted faces.</p>
<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>
</div>
</div>
</div>
)}
{editingFace && (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<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">
<div className="w-full flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Manual Crop Adjust (Drag to Pan)</h3>
<button onClick={() => setEditingFace(null)} className="text-neutral-400 hover:text-white"><XIcon size={20}/></button>
</div>
<div
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'}`}
onMouseDown={(e) => {
setDragState({
isDragging: true,
startX: e.clientX,
startY: e.clientY,
initialOffsetX: editingFace.manualOffsets.x,
initialOffsetY: editingFace.manualOffsets.y
});
}}
onMouseMove={(e) => {
if (!dragState.isDragging || !editingFace) return;
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
// Scale drag distance based on image zoom and box size to feel natural
const scale = Math.max(1, editingFace.originalBox.width / 128) / editingFace.manualOffsets.zoom;
updateManualCrop({
x: dragState.initialOffsetX - (dx * scale),
y: dragState.initialOffsetY - (dy * scale)
});
}}
onMouseUp={() => setDragState(prev => ({ ...prev, isDragging: false }))}
onMouseLeave={() => setDragState(prev => ({ ...prev, isDragging: false }))}
>
<img
src={editingFace.previewUrl}
style={{ imageRendering: editingFace.manualOffsets.resolution !== 'auto' && parseInt(editingFace.manualOffsets.resolution) < 150 ? 'pixelated' : 'auto' }}
className="max-w-full max-h-full object-contain pointer-events-none select-none"
alt="Preview"
draggable="false"
/>
</div>
<div className="w-full space-y-5">
<div>
<label className="flex justify-between text-sm text-neutral-400 mb-2">
<span>Export Resolution</span>
<span>{editingFace.manualOffsets.resolution === 'auto' ? 'Auto' : `${editingFace.manualOffsets.resolution}x${editingFace.manualOffsets.resolution}`}</span>
</label>
<select
value={editingFace.manualOffsets.resolution}
onChange={(e) => updateManualCrop({ resolution: e.target.value })}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-2.5 text-white outline-none focus:border-indigo-500"
>
<option value="auto">Auto (Original Detected Size)</option>
<option value="56">56 x 56</option>
<option value="128">128 x 128</option>
<option value="256">256 x 256</option>
<option value="512">512 x 512</option>
</select>
</div>
<div>
<label className="flex justify-between text-sm text-neutral-400 mb-2">
<span>Zoom</span>
<span>{editingFace.manualOffsets.zoom.toFixed(1)}x</span>
</label>
<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" />
</div>
</div>
<div className="w-full flex gap-3 mt-8">
<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>
<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>
</div>
</div>
</div>
)}
</div>
);
}
// Mount the application
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<FaceExtractApp />);
</script>
</body>
</html>