Download immager from trysem/splug: direct link, hf CLI and curl.
- Browser
- Download file 47.8 kB
-
https://huggingface.co/trysem/splug/resolve/main/immager
- Command line
-
hf download hf://trysem/splug/immager
-
curl -L -o immager https://huggingface.co/trysem/splug/resolve/main/immager
47.8 kB
| <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> |