Download qwa.html from trysem/splug: direct link, hf CLI and curl.
- Browser
- Download file 253 kB
-
https://huggingface.co/trysem/splug/resolve/main/qwa.html
- Command line
-
hf download hf://trysem/splug/qwa.html
-
curl -L -o qwa.html https://huggingface.co/trysem/splug/resolve/main/qwa.html
253 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AI Extraction Studio</title> | |
| <!-- Unified Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- Unified Fonts --> | |
| <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&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <!-- Icons for Videoflow --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <!-- Shared Libraries --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.12/dist/face-api.js"></script> | |
| <!-- React & Babel for Immager --> | |
| <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> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <style> | |
| /* Base Resets */ | |
| body { margin: 0; padding: 0; overflow-x: hidden; background-color: #000; } | |
| /* --- Immager Custom CSS --- */ | |
| @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; } | |
| .immager-scrollbar::-webkit-scrollbar { width: 6px; } | |
| .immager-scrollbar::-webkit-scrollbar-track { background: transparent; } | |
| .immager-scrollbar::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 10px; border: 1px solid #171717; } | |
| .immager-scrollbar::-webkit-scrollbar-thumb:hover { background: #6366f1; } | |
| /* --- Videoflow Custom CSS --- */ | |
| .glass-panel { background: rgba(255, 255, 255, 0.03); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.08); } | |
| .gradient-text { background: linear-gradient(135deg, #818cf8 0%, #c084fc 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } | |
| .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } | |
| #results-container::-webkit-scrollbar { width: 5px; } | |
| #results-container::-webkit-scrollbar-track { background: transparent; } | |
| #results-container::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; } | |
| .scan-line { position: absolute; top: 0; left: 0; width: 100%; height: 2px; background: #818cf8; box-shadow: 0 0 15px #818cf8; animation: scan 2s linear infinite; display: none; z-index: 10; } | |
| @keyframes scan { 0% { top: 0%; } 100% { top: 100%; } } | |
| .toggle-checkbox:checked { right: 0; border-color: #6366f1; } | |
| .toggle-checkbox:checked + .toggle-label { background-color: #6366f1; } | |
| .toggle-checkbox { right: 4px; z-index: 1; border-color: #e2e8f0; transition: all 0.3s; } | |
| .toggle-label { background-color: #cbd5e1; transition: all 0.3s; } | |
| /* --- URL Fetcher Custom CSS --- */ | |
| #url-input-text::-webkit-scrollbar { width: 6px; } | |
| #url-input-text::-webkit-scrollbar-track { background: transparent; } | |
| #url-input-text::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; border: 1px solid #0f172a; } | |
| #url-input-text::-webkit-scrollbar-thumb:hover { background: #14b8a6; } | |
| #url-editor-textarea::-webkit-scrollbar { width: 8px; } | |
| #url-editor-textarea::-webkit-scrollbar-track { background: transparent; } | |
| #url-editor-textarea::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; border: 2px solid #020617; } | |
| #url-editor-textarea::-webkit-scrollbar-thumb:hover { background: #14b8a6; } | |
| @keyframes urlFloat { | |
| 0% { transform: translateY(0px); } | |
| 50% { transform: translateY(-12px); } | |
| 100% { transform: translateY(0px); } | |
| } | |
| .url-float-anim { animation: urlFloat 4s ease-in-out infinite; } | |
| .url-zoom-enter { opacity: 0; transform: scale(0.7); transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); } | |
| .url-zoom-active { opacity: 1; transform: scale(1); } | |
| /* --- Minimal Queue Animation --- */ | |
| .queued-pulse { position: relative; } | |
| .queued-pulse::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 0; | |
| border-radius: 0.75rem; | |
| box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); | |
| animation: minimal-pulse 1.5s ease-in-out infinite alternate; | |
| pointer-events: none; | |
| z-index: 50; | |
| } | |
| @keyframes minimal-pulse { | |
| 0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.2); } | |
| 100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); background: rgba(99, 102, 241, 0.1); } | |
| } | |
| /* --- Global Clipboard CSS --- */ | |
| #clipboard-items::-webkit-scrollbar { width: 5px; } | |
| #clipboard-items::-webkit-scrollbar-track { background: transparent; } | |
| #clipboard-items::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ========================================== --> | |
| <!-- GLOBAL UI: ASSET CLIPBOARD --> | |
| <!-- ========================================== --> | |
| <!-- Floating Toggle Button --> | |
| <div id="global-clipboard-toggle" class="fixed right-0 top-1/2 -translate-y-1/2 z-[10000] bg-indigo-600 text-white p-3.5 rounded-l-2xl cursor-pointer shadow-[-5px_0_20px_rgba(79,70,229,0.3)] hover:pr-5 transition-all duration-300 flex items-center gap-2 group border border-r-0 border-white/10"> | |
| <i class="fas fa-clipboard-list text-xl group-hover:scale-110 transition-transform"></i> | |
| <span id="clipboard-badge" class="absolute -top-2 -left-2 bg-rose-500 text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center border-2 border-neutral-900 shadow-lg hidden">0</span> | |
| </div> | |
| <!-- Sliding Dock Panel --> | |
| <div id="global-clipboard-panel" class="fixed right-0 top-0 h-full w-80 bg-neutral-950/95 backdrop-blur-xl z-[10001] transform translate-x-full transition-transform duration-300 shadow-[-10px_0_30px_rgba(0,0,0,0.8)] border-l border-white/10 flex flex-col font-sans"> | |
| <div class="p-5 border-b border-white/10 flex justify-between items-center bg-black/40"> | |
| <h2 class="text-white font-bold tracking-wide flex items-center"><i class="fas fa-box-open mr-2 text-indigo-400"></i> Asset Clipboard</h2> | |
| <button id="close-clipboard" class="text-neutral-400 hover:text-white transition-colors w-8 h-8 flex items-center justify-center rounded-lg hover:bg-white/10"><i class="fas fa-chevron-right"></i></button> | |
| </div> | |
| <div id="clipboard-items" class="flex-1 overflow-y-auto p-4 grid grid-cols-2 gap-3 content-start"> | |
| <div id="clipboard-empty" class="col-span-2 text-center text-neutral-500 text-sm mt-12 flex flex-col items-center"> | |
| <i class="fas fa-inbox text-4xl mb-4 opacity-30"></i> | |
| <p>Clipboard is empty.</p> | |
| <p class="text-xs mt-1 opacity-70">Save assets here to use across apps.</p> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t border-white/10 bg-black/60 space-y-2"> | |
| <button id="clipboard-send-immager" disabled class="w-full bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-bold py-2.5 rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"> | |
| <i class="fas fa-magic"></i> Open all in Immager | |
| </button> | |
| <div class="flex gap-2"> | |
| <button id="clipboard-download-zip" disabled class="flex-1 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-bold py-2 rounded-xl transition-colors flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <i class="fas fa-download"></i> ZIP | |
| </button> | |
| <button id="clipboard-clear" disabled class="px-4 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 text-sm font-bold py-2 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed" title="Clear Clipboard"> | |
| <i class="fas fa-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ========================================== --> | |
| <!-- GLOBAL UI: APP SWITCHER --> | |
| <!-- ========================================== --> | |
| <div class="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-[9999] bg-black/60 backdrop-blur-xl border border-white/10 p-1.5 rounded-full flex gap-1 shadow-[0_0_30px_rgba(0,0,0,0.8)]"> | |
| <button onclick="switchApp('immager')" id="tab-immager" class="px-5 py-2.5 rounded-full text-sm font-semibold transition-all duration-300 bg-indigo-600 text-white shadow-lg flex items-center gap-2"> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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> | |
| Immager (Images) | |
| </button> | |
| <button onclick="switchApp('videoflow')" id="tab-videoflow" class="px-5 py-2.5 rounded-full text-sm font-semibold transition-all duration-300 text-white/60 hover:text-white hover:bg-white/10 flex items-center gap-2"> | |
| <i class="fas fa-video"></i> | |
| HumanFrame (Video) | |
| </button> | |
| <button onclick="switchApp('urlfetcher')" id="tab-urlfetcher" class="px-5 py-2.5 rounded-full text-sm font-semibold transition-all duration-300 text-white/60 hover:text-white hover:bg-white/10 flex items-center gap-2"> | |
| <i class="fas fa-link"></i> | |
| URL Loader | |
| </button> | |
| </div> | |
| <!-- ========================================== --> | |
| <!-- APP 1: IMMAGER (REACT) --> | |
| <!-- ========================================== --> | |
| <div id="app-immager" class="w-full min-h-screen bg-neutral-950 text-neutral-100 font-sans selection:bg-indigo-500/30"> | |
| <div id="root"></div> | |
| </div> | |
| <!-- ========================================== --> | |
| <!-- APP 2: VIDEOFLOW (VANILLA JS) --> | |
| <!-- ========================================== --> | |
| <div id="app-videoflow" class="w-full min-h-screen bg-[#0f172a] text-slate-200 selection:bg-indigo-500/30 hidden relative" style="font-family: 'Inter', sans-serif;"> | |
| <!-- Navbar --> | |
| <nav class="border-b border-white/5 bg-[#0f172a]/80 backdrop-blur-md sticky top-0 z-50"> | |
| <div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/20"> | |
| <i class="fas fa-user-check text-white text-lg"></i> | |
| </div> | |
| <h1 class="text-xl font-bold tracking-tight">HumanFrame<span class="gradient-text">AI</span></h1> | |
| </div> | |
| <div class="flex gap-3"> | |
| <div id="model-badge" class="flex items-center gap-2 bg-slate-800/50 px-3 py-1.5 rounded-full border border-white/5 text-xs font-medium text-slate-400"> | |
| <span class="status-dot bg-amber-500 animate-pulse"></span> MediaPipe Loading... | |
| </div> | |
| <div id="face-badge" class="flex items-center gap-2 bg-slate-800/50 px-3 py-1.5 rounded-full border border-white/5 text-xs font-medium text-slate-400 hidden"> | |
| <span class="status-dot bg-amber-500 animate-pulse"></span> FaceAPI Loading... | |
| </div> | |
| </div> | |
| </div> | |
| </nav> | |
| <main class="max-w-7xl mx-auto px-6 py-10 pb-24"> | |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-8"> | |
| <!-- Left Panel: Configuration --> | |
| <div class="lg:col-span-4 space-y-6"> | |
| <!-- Upload Zone --> | |
| <div id="drop-zone" class="glass-panel rounded-2xl p-8 text-center border-2 border-dashed border-indigo-500/20 hover:border-indigo-500/50 transition-all cursor-pointer group relative overflow-hidden"> | |
| <input type="file" id="video-input" class="hidden" accept="video/*"> | |
| <div class="relative z-10"> | |
| <div class="w-14 h-14 bg-indigo-500/10 rounded-2xl flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform duration-300"> | |
| <i class="fas fa-video text-indigo-400 text-xl"></i> | |
| </div> | |
| <h3 class="text-md font-semibold text-white mb-1">Upload Video</h3> | |
| <p class="text-xs text-slate-400">Drag & drop or click to browse</p> | |
| </div> | |
| </div> | |
| <!-- Settings & Advanced --> | |
| <div id="settings-panel" class="glass-panel rounded-2xl p-6 space-y-6 opacity-40 pointer-events-none transition-all"> | |
| <!-- Basic Engine --> | |
| <div class="space-y-4"> | |
| <div class="flex items-center justify-between border-b border-white/5 pb-2"> | |
| <h4 class="font-bold text-white text-xs uppercase tracking-wider">Engine Settings</h4> | |
| </div> | |
| <div class="space-y-2"> | |
| <label class="text-xs font-semibold text-slate-400 flex justify-between"> | |
| Scan Interval | |
| <span id="interval-val" class="text-indigo-400">0.5s</span> | |
| </label> | |
| <input type="range" id="scan-rate" min="0.1" max="2.0" step="0.1" value="0.5" class="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-500"> | |
| </div> | |
| <div class="space-y-2"> | |
| <label class="text-xs font-semibold text-slate-400 flex justify-between"> | |
| Detection Confidence | |
| <span id="conf-val" class="text-indigo-400">50%</span> | |
| </label> | |
| <input type="range" id="confidence" min="0.3" max="0.9" step="0.05" value="0.5" class="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-indigo-500"> | |
| </div> | |
| </div> | |
| <!-- Advanced Features --> | |
| <div class="space-y-4"> | |
| <div class="flex items-center justify-between border-b border-white/5 pb-2"> | |
| <h4 class="font-bold text-white text-xs uppercase tracking-wider text-purple-400">Advanced Extraction</h4> | |
| </div> | |
| <!-- Extract All Frames Toggle --> | |
| <div class="flex items-center justify-between mb-4 border-b border-white/5 pb-4"> | |
| <div class="flex flex-col"> | |
| <span class="text-sm font-medium text-slate-200">Extract Full Frames</span> | |
| <span class="text-[10px] text-slate-500">Extracts entire frames, skipping AI filters</span> | |
| </div> | |
| <div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"> | |
| <input type="checkbox" name="toggle" id="extract-all-toggle" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer"/> | |
| <label for="extract-all-toggle" class="toggle-label block overflow-hidden h-5 rounded-full bg-slate-600 cursor-pointer"></label> | |
| </div> | |
| </div> | |
| <div id="advanced-filters-container" class="space-y-4 transition-all duration-300"> | |
| <!-- Smart Body Crop Toggle --> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex flex-col"> | |
| <span class="text-sm font-medium text-slate-200">Smart Body Crop</span> | |
| <span class="text-[10px] text-slate-500">Extracts the person bounding box</span> | |
| </div> | |
| <div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"> | |
| <input type="checkbox" name="toggle" id="auto-crop-toggle" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer"/> | |
| <label for="auto-crop-toggle" class="toggle-label block overflow-hidden h-5 rounded-full bg-slate-600 cursor-pointer"></label> | |
| </div> | |
| </div> | |
| <!-- 512x512 Face Crop Toggle --> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex flex-col"> | |
| <span class="text-sm font-medium text-slate-200">Tight Face Crop (512px)</span> | |
| <span class="text-[10px] text-slate-500">Extracts faces specifically in 512x512</span> | |
| </div> | |
| <div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"> | |
| <input type="checkbox" name="toggle" id="face-crop-toggle" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer"/> | |
| <label for="face-crop-toggle" class="toggle-label block overflow-hidden h-5 rounded-full bg-slate-600 cursor-pointer"></label> | |
| </div> | |
| </div> | |
| <!-- Require Visible Face Toggle --> | |
| <div class="flex items-center justify-between"> | |
| <div class="flex flex-col"> | |
| <span class="text-sm font-medium text-slate-200">Require Visible Face</span> | |
| <span class="text-[10px] text-slate-500">Skip frames with bodies but no faces</span> | |
| </div> | |
| <div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"> | |
| <input type="checkbox" name="toggle" id="require-face-toggle" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer"/> | |
| <label for="require-face-toggle" class="toggle-label block overflow-hidden h-5 rounded-full bg-slate-600 cursor-pointer"></label> | |
| </div> | |
| </div> | |
| <!-- Target Face Match --> | |
| <div class="bg-slate-800/40 p-3 rounded-xl border border-white/5"> | |
| <div class="flex flex-col mb-2"> | |
| <span class="text-sm font-medium text-slate-200">Target Face Match</span> | |
| <span class="text-[10px] text-slate-500">Only extract frames containing these people</span> | |
| </div> | |
| <div id="target-faces-container" class="flex flex-wrap gap-3 mt-3 empty:hidden"></div> | |
| <div class="flex items-center gap-3 mt-3"> | |
| <div id="face-upload-btn" class="flex-grow bg-slate-700 hover:bg-slate-600 text-xs text-center py-2 rounded-lg cursor-pointer transition-colors border border-dashed border-slate-500"> | |
| <i class="fas fa-camera mr-1"></i> Upload Target Face(s) | |
| </div> | |
| <input type="file" id="face-input" class="hidden" accept="image/*" multiple> | |
| </div> | |
| <p id="face-status-text" class="text-[10px] text-amber-400 mt-2 hidden text-center"><i class="fas fa-spinner animate-spin"></i> Analyzing face(s)...</p> | |
| </div> | |
| </div> | |
| </div> | |
| <button id="start-btn" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 rounded-xl shadow-xl shadow-indigo-500/10 transition-all flex items-center justify-center gap-3"> | |
| <i class="fas fa-microchip"></i> Start AI Extraction | |
| </button> | |
| </div> | |
| <!-- Monitoring Window --> | |
| <div class="glass-panel rounded-2xl overflow-hidden relative shadow-2xl group border-2 border-[#0f172a]"> | |
| <div class="scan-line" id="scanner"></div> | |
| <canvas id="preview-canvas" class="w-full aspect-video bg-black object-contain"></canvas> | |
| <div class="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent flex items-center justify-between"> | |
| <span class="text-[10px] font-bold tracking-widest text-indigo-400 uppercase">Live Monitor</span> | |
| <div id="fps-counter" class="text-[10px] font-mono text-slate-400">-- FPS</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right Panel: Results Gallery --> | |
| <div class="lg:col-span-8 flex flex-col h-[calc(100vh-160px)] min-h-[600px]"> | |
| <div class="glass-panel rounded-3xl flex flex-col h-full overflow-hidden border border-white/10 relative"> | |
| <!-- Toolbar --> | |
| <div class="p-6 border-b border-white/5 flex flex-col md:flex-row gap-4 items-start md:items-center justify-between bg-white/[0.02]"> | |
| <div> | |
| <h2 class="text-xl font-bold text-white">Detection Gallery</h2> | |
| <p id="stats-text" class="text-sm text-slate-500">System idle. Awaiting video upload.</p> | |
| </div> | |
| <div class="flex flex-wrap gap-2"> | |
| <button id="select-all-btn" class="hidden px-4 py-2.5 text-slate-300 hover:text-white text-sm font-medium transition-colors border border-transparent hover:border-white/10 rounded-xl"> | |
| Select All | |
| </button> | |
| <button id="export-immager-btn" class="hidden px-5 py-2.5 bg-purple-600 hover:bg-purple-500 text-white text-sm font-bold rounded-xl transition-all flex items-center gap-2 shadow-lg shadow-purple-500/20"> | |
| <i class="fas fa-magic"></i> Export All to Immager | |
| </button> | |
| <button id="download-btn" class="hidden px-5 py-2.5 bg-emerald-500 hover:bg-emerald-400 text-white text-sm font-bold rounded-xl transition-all flex items-center gap-2 shadow-lg shadow-emerald-500/20"> | |
| <i class="fas fa-file-export"></i> Download All | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Progress Bar --> | |
| <div id="progress-container" class="px-6 py-4 bg-indigo-500/5 hidden border-b border-white/5"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <span class="text-xs font-bold text-indigo-300 uppercase tracking-tighter" id="status-label">Analyzing Frames...</span> | |
| <span class="text-xs font-mono text-indigo-300" id="progress-percent">0%</span> | |
| </div> | |
| <div class="w-full bg-white/5 h-1.5 rounded-full overflow-hidden"> | |
| <div id="progress-bar" class="h-full bg-indigo-500 transition-all duration-300 shadow-[0_0_10px_#6366f1]" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <!-- Gallery Grid --> | |
| <div id="results-container" class="flex-grow overflow-y-auto p-6 relative"> | |
| <div id="results" class="flex flex-wrap gap-4 content-start"></div> | |
| <!-- Placeholder --> | |
| <div id="empty-state" class="absolute inset-0 flex flex-col items-center justify-center opacity-20"> | |
| <i class="fas fa-images text-7xl mb-6"></i> | |
| <p class="text-lg font-medium">No frames extracted yet</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <video id="hidden-video" class="hidden" muted></video> | |
| <!-- Fullscreen Image Preview Modal --> | |
| <div id="video-preview-modal" class="fixed inset-0 z-[100] bg-black/90 hidden flex items-center justify-center p-4 backdrop-blur-md"> | |
| <button id="close-preview" class="absolute top-6 right-6 text-white text-4xl hover:text-indigo-400 transition-colors z-50"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| <img id="preview-modal-img" src="" class="max-w-full max-h-[90vh] object-contain rounded-lg shadow-2xl border border-white/10"> | |
| </div> | |
| </div> | |
| <!-- ========================================== --> | |
| <!-- APP 3: URL FETCHER (VANILLA JS) --> | |
| <!-- ========================================== --> | |
| <div id="app-urlfetcher" class="w-full min-h-screen bg-[#0f172a] text-slate-200 hidden relative" style="font-family: 'Inter', sans-serif;"> | |
| <!-- Navbar --> | |
| <nav class="border-b border-white/5 bg-[#0f172a]/80 backdrop-blur-md sticky top-0 z-50"> | |
| <div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-10 h-10 bg-teal-600 rounded-xl flex items-center justify-center shadow-lg shadow-teal-500/20"> | |
| <i class="fas fa-link text-white text-lg"></i> | |
| </div> | |
| <h1 class="text-xl font-bold tracking-tight">URL<span class="text-teal-400">Fetch</span></h1> | |
| </div> | |
| </div> | |
| </nav> | |
| <main class="w-[98%] max-w-[2400px] mx-auto px-4 py-6 pb-24"> | |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-6"> | |
| <!-- Left Panel --> | |
| <div class="lg:col-span-3 space-y-6"> | |
| <div class="glass-panel rounded-2xl p-6 space-y-4 border border-white/10"> | |
| <h2 class="text-lg font-bold text-white mb-2">Source URLs</h2> | |
| <!-- Fusker Link Generator (Moved to top) --> | |
| <div class="mb-5 pb-5 border-b border-slate-700"> | |
| <h3 class="text-sm font-bold text-teal-400 mb-3"><i class="fas fa-magic"></i> Fusker Generator</h3> | |
| <!-- Pre-filled value requires backspacing --> | |
| <input type="text" id="fusker-input" value="http://example.com/img[01-10].jpg" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2.5 text-xs text-slate-300 focus:outline-none focus:border-teal-500 mb-3" placeholder="http://example.com/img[01-10].jpg"> | |
| <div class="flex items-center justify-between gap-4 mb-3"> | |
| <label class="flex items-center text-[10px] text-slate-400 cursor-pointer"> | |
| <input type="checkbox" id="fusker-save-txt" class="mr-1.5 accent-teal-500"> Save to .txt | |
| </label> | |
| <label class="flex items-center text-[10px] text-slate-400 cursor-pointer"> | |
| <input type="checkbox" id="fusker-open-tabs" class="mr-1.5 accent-teal-500"> Open in New Tabs | |
| </label> | |
| </div> | |
| <button id="fusker-btn" class="w-full bg-slate-800 hover:bg-slate-700 text-teal-400 border border-slate-600 font-medium py-2 rounded-lg transition-all text-xs flex items-center justify-center gap-2"> | |
| Generate Links | |
| </button> | |
| </div> | |
| <!-- Manual Paste Area --> | |
| <div class="space-y-2"> | |
| <div class="flex justify-between items-center mb-1"> | |
| <label class="text-xs font-semibold text-slate-400">Paste links (one per line)</label> | |
| <button id="url-clear-input-btn" class="text-[10px] text-red-400 hover:text-red-300 transition-colors bg-red-500/10 hover:bg-red-500/20 px-2 py-1 rounded font-medium"><i class="fas fa-eraser"></i> Clear</button> | |
| </div> | |
| <div class="relative group"> | |
| <textarea id="url-input-text" rows="5" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-3 pr-12 text-sm text-slate-300 focus:outline-none focus:border-teal-500 transition-colors resize-y min-h-[100px]" placeholder="https://example.com/img1.jpg https://example.com/img2.png"></textarea> | |
| <!-- Floating Popup Text Edit Button --> | |
| <button id="url-expand-input-btn" class="absolute top-2 right-2 bg-slate-800 hover:bg-teal-600 border border-slate-700 hover:border-teal-500 text-slate-400 hover:text-white w-8 h-8 rounded-lg shadow-md transition-all flex items-center justify-center opacity-70 hover:opacity-100" title="Pop out Text Editor"> | |
| <i class="fas fa-expand-arrows-alt"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="relative flex py-2 items-center"> | |
| <div class="flex-grow border-t border-slate-700"></div> | |
| <span class="flex-shrink-0 mx-4 text-slate-500 text-xs font-medium uppercase">OR</span> | |
| <div class="flex-grow border-t border-slate-700"></div> | |
| </div> | |
| <div id="url-drop-zone" class="bg-slate-900 border-2 border-dashed border-slate-700 hover:border-teal-500 rounded-xl p-5 text-center transition-colors cursor-pointer group"> | |
| <input type="file" id="url-file-input" class="hidden" accept=".txt"> | |
| <i class="fas fa-file-alt text-xl text-slate-500 group-hover:text-teal-400 transition-colors mb-2"></i> | |
| <h3 class="text-sm font-medium text-slate-300">Upload .txt File</h3> | |
| <p class="text-[10px] text-slate-500 mt-1" id="url-file-name">Drag & drop or click</p> | |
| </div> | |
| <!-- Import Local Images Button --> | |
| <div> | |
| <input type="file" id="url-local-import" class="hidden" multiple accept="image/*"> | |
| <button id="url-import-btn" class="w-full bg-slate-800 hover:bg-slate-700 text-indigo-400 border border-slate-600 font-medium py-3 rounded-xl transition-all text-sm flex items-center justify-center gap-2 shadow-sm hover:shadow-md hover:border-indigo-500/50"> | |
| <i class="fas fa-folder-open"></i> Import Local Images | |
| </button> | |
| </div> | |
| <!-- Upgraded Fetch Button --> | |
| <button id="url-fetch-btn" class="w-full bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-400 hover:to-emerald-400 text-white font-bold py-3.5 rounded-xl shadow-[0_0_20px_rgba(20,184,166,0.3)] hover:shadow-[0_0_25px_rgba(20,184,166,0.5)] transform hover:-translate-y-0.5 transition-all duration-300 flex items-center justify-center gap-3 text-base mt-6 border border-teal-400/30"> | |
| <i class="fas fa-cloud-download-alt text-xl drop-shadow-md"></i> | |
| <span class="drop-shadow-md tracking-wide">Fetch Images</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Right Panel --> | |
| <div class="lg:col-span-9 flex flex-col h-[calc(100vh-140px)] min-h-[600px]"> | |
| <div class="glass-panel rounded-3xl flex flex-col h-full overflow-hidden border border-white/10 relative"> | |
| <!-- Toolbar --> | |
| <div class="p-6 border-b border-white/5 flex flex-wrap gap-4 items-center justify-between bg-white/[0.02]"> | |
| <div> | |
| <h2 class="text-xl font-bold text-white">Fetched Gallery</h2> | |
| <p id="url-stats-text" class="text-sm text-slate-500">No images loaded yet.</p> | |
| </div> | |
| <div class="flex flex-wrap gap-2"> | |
| <button id="url-clear-btn" class="hidden px-4 py-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 text-sm font-medium transition-colors border border-transparent rounded-xl"> | |
| <i class="fas fa-trash-alt"></i> Clear Gallery | |
| </button> | |
| <button id="url-select-all-btn" class="hidden px-4 py-2 text-slate-300 hover:text-white text-sm font-medium transition-colors border border-transparent hover:border-white/10 rounded-xl"> | |
| Select All | |
| </button> | |
| <button id="url-rembg-btn" class="hidden px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-bold rounded-xl transition-all shadow-lg flex items-center gap-2"> | |
| <i class="fas fa-eraser"></i> Remove BG | |
| </button> | |
| <button id="url-export-immager-btn" class="hidden px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-bold rounded-xl transition-all shadow-lg flex items-center gap-2"> | |
| <i class="fas fa-magic"></i> Export to Immager | |
| </button> | |
| <button id="url-download-btn" class="hidden px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-bold rounded-xl transition-all shadow-lg flex items-center gap-2"> | |
| <i class="fas fa-file-archive"></i> Download ZIP | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Progress Bar --> | |
| <div id="url-progress-container" class="px-6 py-4 bg-teal-500/5 hidden border-b border-white/5"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <span class="text-xs font-bold text-teal-300 uppercase tracking-tighter" id="url-status-label">Fetching URLs...</span> | |
| <span class="text-xs font-mono text-teal-300" id="url-progress-percent">0/0</span> | |
| </div> | |
| <div class="w-full bg-white/5 h-1.5 rounded-full overflow-hidden"> | |
| <div id="url-progress-bar" class="h-full bg-teal-500 transition-all duration-300" style="width: 0%"></div> | |
| </div> | |
| </div> | |
| <!-- Gallery Grid --> | |
| <div id="url-results-container" class="flex-grow overflow-y-auto p-6 relative"> | |
| <div id="url-results" class="flex flex-wrap gap-4 content-start"></div> | |
| <!-- Placeholder --> | |
| <div id="url-empty-state" class="absolute inset-0 flex flex-col items-center justify-center opacity-20"> | |
| <i class="fas fa-images text-7xl mb-6"></i> | |
| <p class="text-lg font-medium">Ready to fetch</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Popup Modal for URL App (Fullscreen) --> | |
| <div id="url-preview-modal" class="fixed inset-0 z-[100] bg-black/95 flex flex-col items-center justify-center p-4 backdrop-blur-md opacity-0 pointer-events-none transition-opacity duration-300 hidden"> | |
| <button id="url-download-preview" class="absolute top-6 right-20 text-white text-3xl hover:text-emerald-400 transition-colors z-50" title="Download Image"> | |
| <i class="fas fa-download"></i> | |
| </button> | |
| <button id="url-close-preview" class="absolute top-6 right-6 text-white text-4xl hover:text-teal-400 transition-colors z-50"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| <div id="url-preview-viewport" class="relative flex items-center justify-center w-full h-[85vh] overflow-hidden"> | |
| <div id="url-preview-wrapper" class="w-full h-full flex items-center justify-center cursor-grab"> | |
| <img id="url-preview-modal-img" src="" class="max-w-[90vw] max-h-full object-contain rounded-lg shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10 url-zoom-enter pointer-events-none"> | |
| </div> | |
| </div> | |
| <div class="absolute bottom-8 text-white/40 text-xs tracking-widest font-mono uppercase z-50 pointer-events-none text-center"> | |
| Use <kbd class="px-2 py-1 bg-white/10 rounded mx-1 font-bold">←</kbd> and <kbd class="px-2 py-1 bg-white/10 rounded mx-1 font-bold">→</kbd> to navigate<br> | |
| <span class="mt-2 inline-block">Scroll to Zoom • Drag to Pan</span> | |
| </div> | |
| </div> | |
| <!-- Fullscreen Text Editor Modal --> | |
| <div id="url-editor-modal" class="fixed inset-0 z-[110] bg-black/90 hidden flex flex-col items-center justify-center p-6 backdrop-blur-md opacity-0 pointer-events-none transition-opacity duration-300"> | |
| <div class="w-full max-w-4xl h-full max-h-[80vh] flex flex-col bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden"> | |
| <div class="flex justify-between items-center p-4 border-b border-slate-700 bg-slate-800"> | |
| <h3 class="text-md font-bold text-white"><i class="fas fa-edit text-teal-400 mr-2"></i>Edit URLs</h3> | |
| <div class="flex items-center gap-2"> | |
| <button id="url-editor-clear-btn" class="text-xs text-red-400 hover:text-red-300 px-3 py-1.5 rounded transition-colors"><i class="fas fa-eraser mr-1"></i> Clear</button> | |
| <button id="url-editor-save-btn" class="text-xs bg-teal-600 hover:bg-teal-500 text-white px-4 py-1.5 rounded font-medium shadow transition-colors">Apply & Close</button> | |
| <button id="url-editor-close-btn" class="text-slate-400 hover:text-white transition-colors ml-2"><i class="fas fa-times text-xl"></i></button> | |
| </div> | |
| </div> | |
| <textarea id="url-editor-textarea" class="flex-grow w-full bg-slate-950 p-4 text-sm text-slate-300 focus:outline-none resize-none font-mono leading-relaxed" placeholder="Paste or edit your links here..."></textarea> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ========================================== --> | |
| <!-- LOGIC: APP SWITCHER --> | |
| <!-- ========================================== --> | |
| <script> | |
| window.currentActiveApp = 'immager'; // Track active app globally | |
| function switchApp(appName) { | |
| window.currentActiveApp = appName; | |
| const immagerApp = document.getElementById('app-immager'); | |
| const videoflowApp = document.getElementById('app-videoflow'); | |
| const urlfetcherApp = document.getElementById('app-urlfetcher'); | |
| const tabImmager = document.getElementById('tab-immager'); | |
| const tabVideoflow = document.getElementById('tab-videoflow'); | |
| const tabUrlfetcher = document.getElementById('tab-urlfetcher'); | |
| if (appName === 'immager') { | |
| immagerApp.classList.remove('hidden'); | |
| videoflowApp.classList.add('hidden'); | |
| urlfetcherApp.classList.add('hidden'); | |
| tabImmager.classList.replace('bg-transparent', 'bg-indigo-600'); | |
| tabImmager.classList.replace('text-white/60', 'text-white'); | |
| tabImmager.classList.remove('hover:bg-white/10'); | |
| tabVideoflow.classList.replace('bg-indigo-600', 'bg-transparent'); | |
| tabVideoflow.classList.replace('text-white', 'text-white/60'); | |
| tabVideoflow.classList.add('hover:bg-white/10'); | |
| tabUrlfetcher.classList.replace('bg-teal-600', 'bg-transparent'); | |
| tabUrlfetcher.classList.replace('text-white', 'text-white/60'); | |
| tabUrlfetcher.classList.add('hover:bg-white/10'); | |
| window.scrollTo(0,0); | |
| } else if (appName === 'videoflow') { | |
| immagerApp.classList.add('hidden'); | |
| videoflowApp.classList.remove('hidden'); | |
| urlfetcherApp.classList.add('hidden'); | |
| tabVideoflow.classList.replace('bg-transparent', 'bg-indigo-600'); | |
| tabVideoflow.classList.replace('text-white/60', 'text-white'); | |
| tabVideoflow.classList.remove('hover:bg-white/10'); | |
| tabImmager.classList.replace('bg-indigo-600', 'bg-transparent'); | |
| tabImmager.classList.replace('text-white', 'text-white/60'); | |
| tabImmager.classList.add('hover:bg-white/10'); | |
| tabUrlfetcher.classList.replace('bg-teal-600', 'bg-transparent'); | |
| tabUrlfetcher.classList.replace('text-white', 'text-white/60'); | |
| tabUrlfetcher.classList.add('hover:bg-white/10'); | |
| window.scrollTo(0,0); | |
| } else { | |
| immagerApp.classList.add('hidden'); | |
| videoflowApp.classList.add('hidden'); | |
| urlfetcherApp.classList.remove('hidden'); | |
| tabUrlfetcher.classList.replace('bg-transparent', 'bg-teal-600'); | |
| tabUrlfetcher.classList.replace('text-white/60', 'text-white'); | |
| tabUrlfetcher.classList.remove('hover:bg-white/10'); | |
| tabImmager.classList.replace('bg-indigo-600', 'bg-transparent'); | |
| tabImmager.classList.replace('text-white', 'text-white/60'); | |
| tabImmager.classList.add('hover:bg-white/10'); | |
| tabVideoflow.classList.replace('bg-indigo-600', 'bg-transparent'); | |
| tabVideoflow.classList.replace('text-white', 'text-white/60'); | |
| tabVideoflow.classList.add('hover:bg-white/10'); | |
| window.scrollTo(0,0); | |
| } | |
| // Update clipboard button state when switching apps | |
| if (typeof window.updateClipboardContext === 'function') { | |
| window.updateClipboardContext(); | |
| } | |
| } | |
| </script> | |
| <!-- ========================================== --> | |
| <!-- LOGIC: GLOBAL ASSET CLIPBOARD --> | |
| <!-- ========================================== --> | |
| <script> | |
| (function() { | |
| window.globalClipboard = []; | |
| const cbToggle = document.getElementById('global-clipboard-toggle'); | |
| const cbPanel = document.getElementById('global-clipboard-panel'); | |
| const cbClose = document.getElementById('close-clipboard'); | |
| const cbItems = document.getElementById('clipboard-items'); | |
| const cbBadge = document.getElementById('clipboard-badge'); | |
| const cbEmpty = document.getElementById('clipboard-empty'); | |
| const btnSendImmager = document.getElementById('clipboard-send-immager'); | |
| const btnDownloadZip = document.getElementById('clipboard-download-zip'); | |
| const btnClear = document.getElementById('clipboard-clear'); | |
| cbToggle.onclick = () => cbPanel.classList.remove('translate-x-full'); | |
| cbClose.onclick = () => cbPanel.classList.add('translate-x-full'); | |
| window.updateClipboardContext = () => { | |
| if (window.globalClipboard.length > 0) { | |
| btnSendImmager.disabled = window.currentActiveApp === 'immager'; | |
| } else { | |
| btnSendImmager.disabled = true; | |
| } | |
| }; | |
| // Expose globally so all 3 apps can push to it easily | |
| window.addToClipboard = async (dataUrl, filename) => { | |
| try { | |
| // Fetch works elegantly for both base64 and object/blob URLs | |
| const res = await fetch(dataUrl); | |
| const blob = await res.blob(); | |
| const fileObj = new File([blob], filename || `asset_${Date.now()}.jpg`, { type: blob.type || 'image/jpeg' }); | |
| window.globalClipboard.push({ | |
| id: 'asset_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9), | |
| dataUrl: dataUrl, | |
| file: fileObj | |
| }); | |
| renderClipboard(); | |
| // Visual feedback animation on the toggle button | |
| cbToggle.classList.add('scale-110', 'bg-amber-500'); | |
| cbToggle.classList.remove('bg-indigo-600'); | |
| setTimeout(() => { | |
| cbToggle.classList.remove('scale-110', 'bg-amber-500'); | |
| cbToggle.classList.add('bg-indigo-600'); | |
| }, 300); | |
| } catch(e) { | |
| console.error("Clipboard Addition Error:", e); | |
| } | |
| }; | |
| window.removeFromClipboard = (id) => { | |
| window.globalClipboard = window.globalClipboard.filter(item => item.id !== id); | |
| renderClipboard(); | |
| }; | |
| function renderClipboard() { | |
| if (window.globalClipboard.length > 0) { | |
| cbBadge.innerText = window.globalClipboard.length; | |
| cbBadge.classList.remove('hidden'); | |
| cbEmpty.classList.add('hidden'); | |
| btnSendImmager.disabled = window.currentActiveApp === 'immager'; | |
| btnDownloadZip.disabled = false; | |
| btnClear.disabled = false; | |
| } else { | |
| cbBadge.classList.add('hidden'); | |
| cbEmpty.classList.remove('hidden'); | |
| btnSendImmager.disabled = true; | |
| btnDownloadZip.disabled = true; | |
| btnClear.disabled = true; | |
| } | |
| // Preserve empty state, remove only asset items | |
| Array.from(cbItems.children).forEach(child => { | |
| if (child.id !== 'clipboard-empty') cbItems.removeChild(child); | |
| }); | |
| window.globalClipboard.forEach(item => { | |
| const div = document.createElement('div'); | |
| div.className = 'relative aspect-square rounded-xl overflow-hidden group border border-white/10 shadow-lg animate-in'; | |
| div.innerHTML = ` | |
| <img src="${item.dataUrl}" class="w-full h-full object-cover"> | |
| <button class="absolute top-1 right-1 bg-red-500 hover:bg-red-600 text-white w-6 h-6 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform hover:scale-110 shadow-md" onclick="window.removeFromClipboard('${item.id}')"> | |
| <i class="fas fa-times text-xs"></i> | |
| </button> | |
| `; | |
| cbItems.appendChild(div); | |
| }); | |
| } | |
| btnClear.onclick = () => { | |
| if(confirm("Clear all items from clipboard?")) { | |
| window.globalClipboard = []; | |
| renderClipboard(); | |
| } | |
| }; | |
| btnDownloadZip.onclick = async () => { | |
| if(window.globalClipboard.length === 0) return; | |
| const originalText = btnDownloadZip.innerHTML; | |
| btnDownloadZip.innerHTML = '<i class="fas fa-spinner animate-spin"></i>'; | |
| btnDownloadZip.disabled = true; | |
| try { | |
| const zip = new window.JSZip(); | |
| window.globalClipboard.forEach(item => { | |
| zip.file(item.file.name, item.file); | |
| }); | |
| const content = await zip.generateAsync({type: "blob"}); | |
| const url = URL.createObjectURL(content); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `Clipboard_Assets_${window.globalClipboard.length}.zip`; | |
| a.click(); | |
| } catch(e) { | |
| console.error("ZIP Error", e); | |
| alert("Failed to ZIP clipboard contents."); | |
| } finally { | |
| btnDownloadZip.innerHTML = originalText; | |
| btnDownloadZip.disabled = false; | |
| } | |
| }; | |
| btnSendImmager.onclick = () => { | |
| if(window.globalClipboard.length === 0) return; | |
| const files = window.globalClipboard.map(item => item.file); | |
| // Close the panel | |
| cbPanel.classList.add('translate-x-full'); | |
| // Dispatch event and switch tabs seamlessly | |
| window.dispatchEvent(new CustomEvent('SEND_TO_IMMAGER', { detail: files })); | |
| if(typeof switchApp === 'function') switchApp('immager'); | |
| }; | |
| })(); | |
| </script> | |
| <!-- ========================================== --> | |
| <!-- LOGIC: APP 1 (IMMAGER - REACT) --> | |
| <!-- ========================================== --> | |
| <script type="text/babel"> | |
| const { useState, useEffect, useCallback, useRef } = React; | |
| 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 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>); | |
| const AlertTriangleIcon = ({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.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>); | |
| const EyeIcon = ({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="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>); | |
| const EyeOffIcon = ({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="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/></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 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 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 ClipboardIcon = ({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="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>); | |
| const MoveIcon = ({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="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="19 9 22 12 19 15"/><polyline points="15 19 12 22 9 19"/><line x1="12" y1="2" x2="12" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/></svg>); | |
| // Component for interactive inline cropping on standard batch items | |
| const InlineCropper = ({ face, onSaveBox, standardShape }) => { | |
| const { useState, useEffect, useRef } = React; | |
| const [tempBox, setTempBox] = useState(face.originalBox); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [isResizing, setIsResizing] = useState(false); | |
| const [startPos, setStartPos] = useState({x: 0, y: 0}); | |
| const [startBox, setStartBox] = useState(null); | |
| const imgRef = useRef(null); | |
| // Sync if changed externally | |
| useEffect(() => { setTempBox(face.originalBox); }, [face.originalBox]); | |
| const { x, y, width, height } = tempBox; | |
| const imgW = face.fullWidth; | |
| const imgH = face.fullHeight; | |
| // Math to translate absolute pixels to responsive percentages | |
| const leftPct = (x / imgW) * 100; | |
| const topPct = (y / imgH) * 100; | |
| const widthPct = (width / imgW) * 100; | |
| const heightPct = (height / imgH) * 100; | |
| // Math to counter-shift the bright sliced overlay | |
| const innerLeft = -(x / width) * 100; | |
| const innerTop = -(y / height) * 100; | |
| const innerWidth = (imgW / width) * 100; | |
| const innerHeight = (imgH / height) * 100; | |
| const handlePointerDown = (e, action) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (action === 'drag') setIsDragging(true); | |
| if (action === 'resize') setIsResizing(true); | |
| setStartPos({ x: e.clientX, y: e.clientY }); | |
| setStartBox({ ...tempBox }); | |
| e.target.setPointerCapture(e.pointerId); | |
| }; | |
| const handlePointerMove = (e) => { | |
| if (!isDragging && !isResizing) return; | |
| if (!imgRef.current) return; | |
| const rect = imgRef.current.getBoundingClientRect(); | |
| const scaleX = imgW / rect.width; | |
| const scaleY = imgH / rect.height; | |
| const dx = (e.clientX - startPos.x) * scaleX; | |
| const dy = (e.clientY - startPos.y) * scaleY; | |
| let newBox = { ...startBox }; | |
| if (isDragging) { | |
| newBox.x = Math.max(0, Math.min(imgW - newBox.width, startBox.x + dx)); | |
| newBox.y = Math.max(0, Math.min(imgH - newBox.height, startBox.y + dy)); | |
| } else if (isResizing) { | |
| let newW = Math.max(20, Math.min(imgW - startBox.x, startBox.width + dx)); | |
| let newH = Math.max(20, Math.min(imgH - startBox.y, startBox.height + dy)); | |
| // Enforce aspect ratio constraints dynamically based on right-side panel selection | |
| if (standardShape === 'square') { | |
| const size = Math.min(Math.max(newW, newH), imgW - startBox.x, imgH - startBox.y); | |
| newW = size; | |
| newH = size; | |
| } else if (standardShape === '4:3') { | |
| let calcH = newW * (3/4); | |
| if (calcH > imgH - startBox.y) { | |
| calcH = imgH - startBox.y; | |
| newW = calcH * (4/3); | |
| } | |
| newH = calcH; | |
| } else if (standardShape === '16:9') { | |
| let calcH = newW * (9/16); | |
| if (calcH > imgH - startBox.y) { | |
| calcH = imgH - startBox.y; | |
| newW = calcH * (16/9); | |
| } | |
| newH = calcH; | |
| } else if (standardShape === '9:16') { | |
| let calcH = newW * (16/9); | |
| if (calcH > imgH - startBox.y) { | |
| calcH = imgH - startBox.y; | |
| newW = calcH * (9/16); | |
| } | |
| newH = calcH; | |
| } | |
| // For 'free' or 'original', it remains unconstrained, enabling completely free-form scaling. | |
| newBox.width = newW; | |
| newBox.height = newH; | |
| } | |
| setTempBox(newBox); | |
| }; | |
| const handlePointerUp = (e) => { | |
| if (!isDragging && !isResizing) return; | |
| setIsDragging(false); | |
| setIsResizing(false); | |
| e.target.releasePointerCapture(e.pointerId); | |
| onSaveBox(face.id, tempBox); // Only trigger the expensive canvas regeneration on release | |
| }; | |
| return ( | |
| <div className="relative w-full overflow-hidden bg-neutral-900 rounded-2xl" style={{ touchAction: 'none' }}> | |
| <img ref={imgRef} src={face.sourceUrl} className="w-full h-auto block opacity-70 pointer-events-none select-none" draggable="false" /> | |
| <div className="absolute inset-0 pointer-events-none z-10"> | |
| <div | |
| className="absolute border-[3px] border-indigo-400 shadow-[0_0_0_9999px_rgba(0,0,0,0.6)] pointer-events-auto cursor-move group/cropbox box-border" | |
| style={{ left: `${leftPct}%`, top: `${topPct}%`, width: `${widthPct}%`, height: `${heightPct}%` }} | |
| onPointerDown={(e) => handlePointerDown(e, 'drag')} | |
| onPointerMove={handlePointerMove} | |
| onPointerUp={handlePointerUp} | |
| onPointerCancel={handlePointerUp} | |
| > | |
| <div className="w-full h-full overflow-hidden relative pointer-events-none"> | |
| <img src={face.sourceUrl} className="absolute max-w-none object-fill" style={{ left: `${innerLeft}%`, top: `${innerTop}%`, width: `${innerWidth}%`, height: `${innerHeight}%` }} /> | |
| </div> | |
| <div | |
| className="absolute -bottom-3.5 -right-3.5 w-8 h-8 bg-indigo-500 border-2 border-white rounded-full cursor-nwse-resize flex items-center justify-center pointer-events-auto opacity-0 group-hover/cropbox:opacity-100 transition-opacity shadow-lg" | |
| onPointerDown={(e) => handlePointerDown(e, 'resize')} | |
| onPointerMove={handlePointerMove} | |
| onPointerUp={handlePointerUp} | |
| onPointerCancel={handlePointerUp} | |
| > | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="21 15 21 21 15 21"></polyline><line x1="21" y1="21" x2="15" y2="15"></line></svg> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| 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 [collapsedGroups, setCollapsedGroups] = useState(new Set()); | |
| const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); | |
| const fileInputRef = useRef(null); | |
| const [extractedFaces, setExtractedFaces] = useState([]); | |
| const [matchThreshold, setMatchThreshold] = useState(0.50); | |
| const [processingMode, setProcessingMode] = useState('smart'); // 'smart' | 'standard' | |
| // Added standardShape, batchWidth, and batchHeight to manage standard vs smart states completely independently | |
| const [cropSettings, setCropSettings] = useState({ padding: 0.05, topPadding: 0.2, shape: 'square', standardShape: 'free', batchWidth: 'auto', batchHeight: 'auto' }); | |
| const [showSettings, setShowSettings] = useState(false); | |
| const [editingFace, setEditingFace] = useState(null); | |
| const editorImgRef = useRef(null); | |
| const [dragState, setDragState] = useState({ isDragging: false, startX: 0, startY: 0, initialOffsetX: 0, initialOffsetY: 0 }); | |
| const [dragOverGroupId, setDragOverGroupId] = useState(null); | |
| const [isDraggingFace, setIsDraggingFace] = useState(false); | |
| const draggedFaceRef = useRef(null); | |
| const dragStartTimeoutRef = useRef(null); // Ref to hold the timeout ID | |
| const [pendingTransferFiles, setPendingTransferFiles] = useState(null); | |
| const [extractedBatchIds, setExtractedBatchIds] = useState(new Set()); | |
| // NEW STATES | |
| const [allSmartCollapsed, setAllSmartCollapsed] = useState(false); | |
| const [hideStandardInSmart, setHideStandardInSmart] = useState(true); | |
| const handleModeSwitch = (mode) => { | |
| setProcessingMode(mode); | |
| if (mode === 'smart') setHideStandardInSmart(true); | |
| setCollapsedGroups(prev => { | |
| const next = new Set(prev); | |
| if (mode === 'smart') { | |
| next.add('group-standard-special'); | |
| } else { | |
| next.delete('group-standard-special'); | |
| } | |
| return next; | |
| }); | |
| }; | |
| // Listen for cross-app transfer event | |
| useEffect(() => { | |
| const handleTransfer = (e) => { | |
| setPendingTransferFiles(e.detail); | |
| }; | |
| window.addEventListener('SEND_TO_IMMAGER', handleTransfer); | |
| return () => window.removeEventListener('SEND_TO_IMMAGER', handleTransfer); | |
| }, []); | |
| // Initialize models | |
| useEffect(() => { | |
| const initModels = async () => { | |
| while(!window.faceapi || !window.JSZip) { | |
| await new Promise(r => setTimeout(r, 100)); | |
| } | |
| try { | |
| setStatusText("Loading AI Models (~5MB)..."); | |
| const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.12/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."); | |
| } | |
| }; | |
| initModels(); | |
| }, []); | |
| // Trigger processing of transferred files once models load | |
| useEffect(() => { | |
| if (pendingTransferFiles && pendingTransferFiles.length > 0 && !isModelLoading && !isProcessing) { | |
| processImages(pendingTransferFiles); | |
| setPendingTransferFiles(null); | |
| } | |
| }, [pendingTransferFiles, isModelLoading, isProcessing]); | |
| 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; | |
| } | |
| tw /= manualOffsets.zoom; | |
| th /= manualOffsets.zoom; | |
| let cx = box.x + box.width / 2; | |
| let cy = box.y + box.height / 2 - (box.height * settings.topPadding / 2) + (padY / 2); | |
| 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); | |
| let targetW = cropW; | |
| let targetH = cropH; | |
| // Handle Smart Track manual resolution | |
| if (manualOffsets.resolution && manualOffsets.resolution !== 'auto') { | |
| const res = parseInt(manualOffsets.resolution, 10); | |
| targetW = res; | |
| targetH = settings.shape === 'original' || settings.shape === 'free' ? Math.round(res * (cropH / cropW)) : res; | |
| } | |
| // Handle Standard Batch Crop export resolutions | |
| if (settings.batchWidth !== undefined && settings.batchHeight !== undefined) { | |
| if (settings.batchWidth !== 'auto') targetW = parseInt(settings.batchWidth, 10); | |
| if (settings.batchHeight !== 'auto') targetH = parseInt(settings.batchHeight, 10); | |
| // Calculate dynamic scaling if one dimension is set to "Auto" | |
| if (settings.batchWidth !== 'auto' && settings.batchHeight === 'auto') { | |
| targetH = Math.max(1, Math.round(targetW * (cropH / cropW))); | |
| } else if (settings.batchHeight !== 'auto' && settings.batchWidth === 'auto') { | |
| targetW = Math.max(1, Math.round(targetH * (cropW / cropH))); | |
| } | |
| } | |
| 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 }; | |
| let targetCropSettings; | |
| if (editingFace.isNoFace) { | |
| targetCropSettings = { padding: 0, topPadding: 0, shape: 'original' }; | |
| } else if (editingFace.isStandard) { | |
| targetCropSettings = { padding: 0, topPadding: 0, shape: cropSettings.standardShape }; | |
| } else { | |
| targetCropSettings = cropSettings; | |
| } | |
| const newPreview = generateCrop(editorImgRef.current, editingFace.originalBox, targetCropSettings, 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); | |
| }; | |
| // Receiver function for inline cropper | |
| const saveStandardCropBox = async (faceId, newBox) => { | |
| const face = extractedFaces.find(f => f.id === faceId); | |
| if (!face) return; | |
| const img = new Image(); | |
| img.src = face.sourceUrl; | |
| await new Promise(r => img.onload = r); | |
| const targetCropSettings = { padding: 0, topPadding: 0, shape: cropSettings.standardShape, batchWidth: cropSettings.batchWidth, batchHeight: cropSettings.batchHeight }; | |
| const newCropDataUrl = generateCrop(img, newBox, targetCropSettings, face.manualOffsets); | |
| const updatedFace = { ...face, originalBox: newBox, cropDataUrl: newCropDataUrl }; | |
| setExtractedFaces(prev => prev.map(f => f.id === faceId ? updatedFace : f)); | |
| setFaceGroups(prev => prev.map(g => ({ | |
| ...g, | |
| faces: g.faces.map(f => f.id === faceId ? updatedFace : f) | |
| }))); | |
| }; | |
| const updateGroupName = (groupId, newName) => { | |
| setFaceGroups(prevGroups => prevGroups.map(g => g.id === groupId ? { ...g, name: newName } : g)); | |
| }; | |
| const handleDragStart = (e, faceId, sourceGroupId) => { | |
| e.stopPropagation(); | |
| let itemsToDrag; | |
| if (selectedFaceIds.has(faceId)) { | |
| // If the dragged item is part of a selection, drag the whole selection. | |
| itemsToDrag = Array.from(selectedFaceIds).map(id => { | |
| for (const group of faceGroups) { | |
| if (group.faces.some(f => f.id === id)) { | |
| return { faceId: id, sourceGroupId: group.id }; | |
| } | |
| } | |
| return { faceId: id, sourceGroupId: null }; // Fallback | |
| }); | |
| } else { | |
| // If dragging an unselected item, treat it as a single-item drag without forcing a re-render. | |
| // We defer the selection state update to prevent breaking the browser's drag initialization. | |
| itemsToDrag = [{ faceId, sourceGroupId }]; | |
| } | |
| if (itemsToDrag.length === 0) { | |
| e.preventDefault(); | |
| return; | |
| } | |
| // --- Create a clean drag ghost to avoid capturing UI buttons --- | |
| const ghostContainer = document.createElement('div'); | |
| ghostContainer.id = 'drag-ghost-container'; | |
| ghostContainer.style.position = 'absolute'; | |
| ghostContainer.style.top = '-1000px'; | |
| ghostContainer.style.left = '-1000px'; | |
| if (itemsToDrag.length > 1) { | |
| // Multi-item ghost with stacking effect and count badge | |
| ghostContainer.style.width = '80px'; | |
| ghostContainer.style.height = '80px'; | |
| const previewFacesData = itemsToDrag.slice(0, 3).map(item => extractedFaces.find(f => f.id === item.faceId)?.cropDataUrl); | |
| previewFacesData.reverse().forEach((url, i) => { | |
| if (url) { | |
| const img = new Image(); | |
| img.src = url; | |
| img.style.position = 'absolute'; | |
| img.style.width = '60px'; | |
| img.style.height = '60px'; | |
| img.style.borderRadius = '12px'; | |
| img.style.objectFit = 'cover'; | |
| img.style.border = '2px solid white'; | |
| img.style.boxShadow = '0 4px 6px rgba(0,0,0,0.3)'; | |
| img.style.top = `${(2 - i) * 6}px`; | |
| img.style.left = `${(2 - i) * 6}px`; | |
| img.style.zIndex = i; | |
| ghostContainer.appendChild(img); | |
| } | |
| }); | |
| const badge = document.createElement('div'); | |
| badge.style.cssText = 'position:absolute; bottom:0; right:0; background:#4f46e5; color:white; border-radius:999px; padding:2px 8px; font-size:12px; font-weight:bold; z-index:10; border:2px solid white; box-shadow:0 2px 4px rgba(0,0,0,0.2);'; | |
| badge.innerText = itemsToDrag.length; | |
| ghostContainer.appendChild(badge); | |
| } else { | |
| // Single-item ghost: just a clean image | |
| const face = extractedFaces.find(f => f.id === faceId); | |
| if (face) { | |
| const ghostImg = new Image(); | |
| ghostImg.src = face.cropDataUrl; | |
| ghostImg.style.width = '80px'; | |
| ghostImg.style.height = '80px'; | |
| ghostImg.style.borderRadius = '12px'; | |
| ghostImg.style.objectFit = 'cover'; | |
| ghostImg.style.boxShadow = '0 5px 15px rgba(0,0,0,0.3)'; | |
| ghostContainer.appendChild(ghostImg); | |
| } | |
| } | |
| document.body.appendChild(ghostContainer); | |
| e.dataTransfer.setDragImage(ghostContainer, 40, 40); | |
| // Cleanup the ghost element after the drag operation has started | |
| setTimeout(() => { | |
| if (ghostContainer.parentNode) { | |
| ghostContainer.parentNode.removeChild(ghostContainer); | |
| } | |
| }, 1); | |
| draggedFaceRef.current = itemsToDrag; | |
| e.dataTransfer.effectAllowed = 'move'; | |
| e.dataTransfer.setData('text/plain', itemsToDrag.map(i => i.faceId).join(',')); | |
| // Use a slight delay to ensure the drag operation starts cleanly before state updates. | |
| // Clear any pending timeout to prevent race conditions on very short drags. | |
| if (dragStartTimeoutRef.current) clearTimeout(dragStartTimeoutRef.current); | |
| dragStartTimeoutRef.current = setTimeout(() => { | |
| // Safety: only show drop zones if drag is valid and still active | |
| if (draggedFaceRef.current) setIsDraggingFace(true); | |
| }, 20); | |
| }; | |
| const handleDragEnd = (e) => { | |
| // Always clear any pending drag-start timeout to prevent race conditions. | |
| if (dragStartTimeoutRef.current) clearTimeout(dragStartTimeoutRef.current); | |
| setIsDraggingFace(false); | |
| setDragOverGroupId(null); | |
| draggedFaceRef.current = null; | |
| }; | |
| const handleDragOver = (e, targetGroupId) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); // Critical for nested layouts | |
| e.dataTransfer.dropEffect = 'move'; | |
| if (dragOverGroupId !== targetGroupId) setDragOverGroupId(targetGroupId); | |
| }; | |
| const handleDragLeave = (e) => { e.preventDefault(); }; | |
| const handleDrop = (e, targetGroupId) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // Clear any pending timeout from drag start to prevent race conditions. | |
| if (dragStartTimeoutRef.current) clearTimeout(dragStartTimeoutRef.current); | |
| setDragOverGroupId(null); | |
| // 1. Try to get items from Ref (memory) | |
| let dragItems = draggedFaceRef.current; | |
| // 2. Fallback: Try to get items from DataTransfer (serialized) if Ref is missing | |
| if (!dragItems || dragItems.length === 0) { | |
| try { | |
| const rawIds = e.dataTransfer.getData('text/plain'); | |
| if (rawIds) { | |
| // Reconstruct items. SourceGroupId is unknown but not strictly needed for the move logic below. | |
| dragItems = rawIds.split(',').filter(id => id).map(id => ({ faceId: id.trim(), sourceGroupId: null })); | |
| } | |
| } catch (err) { console.error("Drag fallback failed", err); } | |
| } | |
| if (!dragItems || dragItems.length === 0) { | |
| setIsDraggingFace(false); // Ensure overlay is removed | |
| return; | |
| } | |
| // Prevent dropping a selection back into its own single source group. | |
| const sourceGroupIds = new Set(dragItems.map(item => item.sourceGroupId).filter(id => id)); | |
| if (targetGroupId !== 'new-group' && sourceGroupIds.has(targetGroupId) && sourceGroupIds.size === 1) { | |
| setIsDraggingFace(false); // Reset state if invalid drop | |
| return; | |
| } | |
| setFaceGroups(prevGroups => { | |
| // 1. Collect all faces to be moved from the original groups | |
| const movedFaceIds = new Set(dragItems.map(item => item.faceId)); | |
| let facesToMove = []; | |
| prevGroups.forEach(g => { | |
| g.faces.forEach(f => { | |
| if (movedFaceIds.has(f.id)) { | |
| facesToMove.push(f); | |
| } | |
| }); | |
| }); | |
| if (facesToMove.length === 0) return prevGroups; | |
| // 2. Create the next state for groups, removing moved faces | |
| let nextGroups = prevGroups.map(group => { | |
| // Filter out the moved faces from their original positions | |
| const remainingFaces = group.faces.filter(f => !movedFaceIds.has(f.id)); | |
| return { ...group, faces: remainingFaces }; | |
| }); | |
| // 3. Add faces to the target group | |
| if (targetGroupId === 'new-group') { | |
| const newGroup = { | |
| id: `group-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, | |
| name: '', | |
| baseDescriptor: facesToMove[0].descriptor, | |
| faces: facesToMove | |
| }; | |
| // Insert after Standard group if exists to preserve order, else at top | |
| const stdIndex = nextGroups.findIndex(g => g.isStandardGroup); | |
| if (stdIndex !== -1) { | |
| nextGroups.splice(stdIndex + 1, 0, newGroup); | |
| } else { | |
| nextGroups.unshift(newGroup); | |
| } | |
| } else { | |
| // Find the target group and add the faces | |
| nextGroups = nextGroups.map(group => { | |
| if (group.id === targetGroupId) { | |
| return { ...group, faces: [...group.faces, ...facesToMove] }; | |
| } | |
| return group; | |
| }); | |
| } | |
| // 4. Clean up any groups that are now empty (except special ones like Standard or Unrecognized) | |
| return nextGroups.filter(g => g.faces.length > 0 || g.isStandardGroup || g.isNoFaceGroup || g.id === 'group-noface-special'); | |
| }); | |
| setSelectedFaceIds(new Set()); | |
| // Force cleanup | |
| draggedFaceRef.current = null; | |
| setIsDraggingFace(false); | |
| }; | |
| const exportToSmartTrack = async () => { | |
| const standardGroup = faceGroups.find(g => g.isStandardGroup); | |
| if (!standardGroup) return; | |
| let toProcess = standardGroup.faces.filter(f => selectedFaceIds.has(f.id)); | |
| if (toProcess.length === 0) toProcess = standardGroup.faces; | |
| const itemsToExtract = toProcess.filter(f => !extractedBatchIds.has(f.id)); | |
| if (itemsToExtract.length === 0) return; | |
| setIsProcessing(true); | |
| setStatusText("Preparing for Smart Face Track..."); | |
| try { | |
| const files = []; | |
| for (let i = 0; i < itemsToExtract.length; i++) { | |
| const face = itemsToExtract[i]; | |
| const res = await fetch(face.cropDataUrl); | |
| const blob = await res.blob(); | |
| const file = new File([blob], `batch_to_smart_${i}.jpg`, { type: 'image/jpeg' }); | |
| file.originalStandardFaceId = face.id; // Add a link back to the source standard face | |
| files.push(file); | |
| } | |
| setExtractedBatchIds(prev => { | |
| const next = new Set(prev); | |
| itemsToExtract.forEach(f => next.add(f.id)); | |
| return next; | |
| }); | |
| setSelectedFaceIds(new Set()); | |
| setProcessingMode('smart'); | |
| setHideStandardInSmart(true); | |
| // Automatically collapse standard group when moving to smart face track | |
| setCollapsedGroups(prev => { | |
| const next = new Set(prev); | |
| next.add(standardGroup.id); | |
| return next; | |
| }); | |
| // Short delay to allow UI to visually transition to smart mode | |
| await new Promise(resolve => setTimeout(resolve, 50)); | |
| await processImages(files, 'smart'); | |
| } catch (e) { | |
| console.error("Export to Smart Track failed", e); | |
| alert("Failed to export to Smart Track."); | |
| setIsProcessing(false); | |
| setStatusText("Ready"); | |
| } | |
| }; | |
| const applyGlobalCropSettings = async (newSettings) => { | |
| setCropSettings(newSettings); | |
| if (extractedFaces.length === 0) return; | |
| setIsProcessing(true); | |
| setStatusText("Applying new crop settings..."); | |
| 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; | |
| } | |
| let targetCropSettings; | |
| let boxToUse = face.originalBox; | |
| if (face.isNoFace) { | |
| targetCropSettings = { padding: 0, topPadding: 0, shape: 'original' }; | |
| } else if (face.isStandard) { | |
| targetCropSettings = { padding: 0, topPadding: 0, shape: newSettings.standardShape, batchWidth: newSettings.batchWidth, batchHeight: newSettings.batchHeight }; | |
| const shape = newSettings.standardShape; | |
| if (shape === 'free' || shape === 'original') { | |
| boxToUse = { x: 0, y: 0, width: face.fullWidth, height: face.fullHeight }; | |
| } else if (shape === 'square') { | |
| const minDim = Math.min(face.fullWidth, face.fullHeight); | |
| boxToUse = { x: (face.fullWidth - minDim) / 2, y: (face.fullHeight - minDim) / 2, width: minDim, height: minDim }; | |
| } else if (shape === '4:3') { | |
| let w = face.fullWidth; let h = w * (3/4); | |
| if (h > face.fullHeight) { h = face.fullHeight; w = h * (4/3); } | |
| boxToUse = { x: (face.fullWidth - w) / 2, y: (face.fullHeight - h) / 2, width: w, height: h }; | |
| } else if (shape === '16:9') { | |
| let w = face.fullWidth; let h = w * (9/16); | |
| if (h > face.fullHeight) { h = face.fullHeight; w = h * (16/9); } | |
| boxToUse = { x: (face.fullWidth - w) / 2, y: (face.fullHeight - h) / 2, width: w, height: h }; | |
| } else if (shape === '9:16') { | |
| let w = face.fullWidth; let h = w * (16/9); | |
| if (h > face.fullHeight) { h = face.fullHeight; w = h * (9/16); } | |
| boxToUse = { x: (face.fullWidth - w) / 2, y: (face.fullHeight - h) / 2, width: w, height: h }; | |
| } | |
| } else { | |
| targetCropSettings = newSettings; | |
| } | |
| const newCropDataUrl = generateCrop(img, boxToUse, targetCropSettings, face.manualOffsets || { x: 0, y: 0, zoom: 1, resolution: 'auto' }); | |
| updatedFaces.push({ ...face, originalBox: boxToUse, cropDataUrl: newCropDataUrl }); | |
| } | |
| setExtractedFaces(updatedFaces); | |
| setFaceGroups(prevGroups => prevGroups.map(group => ({ | |
| ...group, | |
| faces: group.faces.map(gFace => { | |
| const updatedFace = updatedFaces.find(uf => uf.id === gFace.id); | |
| // FIX: Explicitly map originalBox so inline crop boxes update instantly from Bulk Constraints | |
| return updatedFace ? { ...gFace, originalBox: updatedFace.originalBox, 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 = []; | |
| const noFaceGroup = { | |
| id: 'group-noface-special', | |
| name: 'Unrecognized', | |
| baseDescriptor: null, | |
| faces: [], | |
| isNoFaceGroup: true | |
| }; | |
| const standardGroup = { | |
| id: 'group-standard-special', | |
| name: 'Batch Processed', | |
| baseDescriptor: null, | |
| faces: [], | |
| isStandardGroup: true | |
| }; | |
| facesToCluster.forEach(face => { | |
| if (face.isNoFace) { | |
| noFaceGroup.faces.push(face); | |
| return; | |
| } | |
| if (face.isStandard) { | |
| standardGroup.faces.push(face); | |
| return; | |
| } | |
| let foundGroup = false; | |
| for (let group of groups) { | |
| if (group.isNoFaceGroup || group.isStandardGroup) continue; // Safety check | |
| 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] | |
| }); | |
| } | |
| }); | |
| groups.sort((a, b) => b.faces.length - a.faces.length); | |
| // Always include the special groups, then filter out any other non-special groups that are empty. | |
| const allGroups = [standardGroup, ...groups, noFaceGroup]; | |
| const visibleGroups = allGroups.filter(g => g.faces.length > 0 || g.isStandardGroup || g.isNoFaceGroup); | |
| setFaceGroups(visibleGroups); | |
| }, []); | |
| const processImages = async (files, forceMode = null) => { | |
| if (!files || files.length === 0) return; | |
| setIsProcessing(true); | |
| // Append rather than replace if adding from VideoFlow while groups exist | |
| const startingFaces = faceGroups.length > 0 ? extractedFaces : []; | |
| const allExtractedFaces = [...startingFaces]; | |
| const modeToUse = forceMode || processingMode; | |
| const newStandardIdsToMarkAsExtracted = new Set(); | |
| 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 sourceUrl = URL.createObjectURL(file); | |
| let standardFace = null; | |
| // If this is a fresh image upload (not a conversion from an existing standard crop), | |
| // create a standard baseline face for it. | |
| if (!file.originalStandardFaceId) { | |
| const fullW = img.width; | |
| const fullH = img.height; | |
| const shape = cropSettings.standardShape; | |
| let box; | |
| if (shape === 'square') { | |
| const minDim = Math.min(fullW, fullH); | |
| box = { x: (fullW - minDim) / 2, y: (fullH - minDim) / 2, width: minDim, height: minDim }; | |
| } else if (shape === '4:3') { | |
| let w = fullW; let h = w * (3/4); | |
| if (h > fullH) { h = fullH; w = h * (4/3); } | |
| box = { x: (fullW - w) / 2, y: (fullH - h) / 2, width: w, height: h }; | |
| } else if (shape === '16:9') { | |
| let w = fullW; let h = w * (9/16); | |
| if (h > fullH) { h = fullH; w = h * (16/9); } | |
| box = { x: (fullW - w) / 2, y: (fullH - h) / 2, width: w, height: h }; | |
| } else if (shape === '9:16') { | |
| let w = fullW; let h = w * (16/9); | |
| if (h > fullH) { h = fullH; w = h * (9/16); } | |
| box = { x: (fullW - w) / 2, y: (fullH - h) / 2, width: w, height: h }; | |
| } else { | |
| box = { x: 0, y: 0, width: fullW, height: fullH }; | |
| } | |
| const stdSettings = { padding: 0, topPadding: 0, shape: shape, batchWidth: cropSettings.batchWidth, batchHeight: cropSettings.batchHeight }; | |
| const stdCropDataUrl = generateCrop(img, box, stdSettings); | |
| standardFace = { | |
| id: `std-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, | |
| sourceFile: file.name, | |
| sourceUrl: sourceUrl, | |
| originalBox: box, | |
| fullWidth: fullW, | |
| fullHeight: fullH, | |
| cropDataUrl: stdCropDataUrl, | |
| descriptor: new Float32Array(128).fill(0), | |
| isNoFace: false, | |
| isStandard: true | |
| }; | |
| allExtractedFaces.push(standardFace); | |
| if (modeToUse === 'smart') { | |
| newStandardIdsToMarkAsExtracted.add(standardFace.id); | |
| } | |
| } | |
| if (modeToUse === 'smart') { | |
| const detections = await window.faceapi.detectAllFaces(img) | |
| .withFaceLandmarks() | |
| .withFaceDescriptors(); | |
| // Determine the ID of the standard face this smart face is derived from. | |
| // It's either from the file (if converting) or the one we just created. | |
| const sourceStandardId = file.originalStandardFaceId || (standardFace ? standardFace.id : null); | |
| if (detections.length === 0) { | |
| // Keep images with no detected faces! | |
| allExtractedFaces.push({ | |
| id: `noface-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, | |
| sourceFile: file.name, | |
| sourceUrl: sourceUrl, | |
| originalBox: { x: 0, y: 0, width: img.width, height: img.height }, // Box spans the whole image | |
| fullWidth: img.width, | |
| fullHeight: img.height, | |
| cropDataUrl: sourceUrl, // Keep original image display | |
| descriptor: new Float32Array(128).fill(0), // Dummy descriptor | |
| isNoFace: true, | |
| isStandard: false, | |
| originalStandardFaceId: sourceStandardId | |
| }); | |
| } else { | |
| detections.forEach((det, idx) => { | |
| const faceCropDataUrl = 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, | |
| fullWidth: img.width, | |
| fullHeight: img.height, | |
| cropDataUrl: faceCropDataUrl, | |
| descriptor: det.descriptor, | |
| isNoFace: false, | |
| isStandard: false, | |
| originalStandardFaceId: sourceStandardId | |
| }); | |
| }); | |
| } | |
| } | |
| } catch (err) { console.error(`Error processing ${file.name}`, err); } | |
| } | |
| if (newStandardIdsToMarkAsExtracted.size > 0) { | |
| setExtractedBatchIds(prev => { | |
| const next = new Set(prev); | |
| newStandardIdsToMarkAsExtracted.forEach(id => next.add(id)); | |
| return next; | |
| }); | |
| } | |
| setStatusText("Organizing faces by person..."); | |
| setExtractedFaces(allExtractedFaces); | |
| clusterFaces(allExtractedFaces, matchThreshold); | |
| setIsProcessing(false); | |
| setProgress(100); | |
| setStatusText("Done"); | |
| }; | |
| const onDrop = (e) => { | |
| e.preventDefault(); | |
| if (isModelLoading || isProcessing) return; | |
| const files = Array.from(e.dataTransfer.files); | |
| processImages(files); | |
| }; | |
| const onFileChange = (e) => { | |
| if (e.target.files && e.target.files.length > 0) { | |
| processImages(Array.from(e.target.files)); | |
| e.target.value = null; // Important to allow re-uploading the exact same file | |
| } | |
| }; | |
| const toggleFace = (id) => { | |
| const next = new Set(selectedFaceIds); | |
| next.has(id) ? next.delete(id) : 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 => allSelected ? next.delete(f.id) : next.add(f.id)); | |
| setSelectedFaceIds(next); | |
| }; | |
| const clearCurrentMode = () => { | |
| setFaceGroups(prev => { | |
| if (processingMode === 'smart') { | |
| // Keep only standard and no-face groups when clearing smart mode | |
| return prev.filter(g => g.isStandardGroup || g.isNoFaceGroup); | |
| } else { // processingMode === 'standard' | |
| // Keep only non-standard groups when clearing standard mode | |
| return prev.filter(g => !g.isStandardGroup); | |
| } | |
| }); | |
| setExtractedFaces(prev => { | |
| if (processingMode === 'smart') return prev.filter(f => f.isStandard || f.isNoFace); | |
| return prev.filter(f => !f.isStandard); | |
| }); | |
| setExtractedBatchIds(new Set()); // This should always be reset as it tracks standard faces exported to smart | |
| setSelectedFaceIds(new Set()); | |
| setProgress(0); | |
| }; | |
| const clearAll = () => { | |
| // Revoke all created blob URLs to prevent memory leaks | |
| const urlsToRevoke = new Set(extractedFaces.map(f => f.sourceUrl).filter(url => url && url.startsWith('blob:'))); | |
| urlsToRevoke.forEach(url => URL.revokeObjectURL(url)); | |
| setFaceGroups([]); setExtractedFaces([]); setSelectedFaceIds(new Set()); setProgress(0); setCollapsedGroups(new Set()); setExtractedBatchIds(new Set()); setHideStandardInSmart(true); | |
| }; | |
| const toggleAllSmartGroups = () => { | |
| const newState = !allSmartCollapsed; | |
| setAllSmartCollapsed(newState); | |
| setCollapsedGroups(prev => { | |
| const next = new Set(prev); | |
| faceGroups.forEach(g => { | |
| if (!g.isStandardGroup && !g.isNoFaceGroup) { | |
| if (newState) next.add(g.id); | |
| else next.delete(g.id); | |
| } | |
| }); | |
| return next; | |
| }); | |
| }; | |
| const confirmDelete = () => { | |
| // Before deleting, determine which source URLs can be safely revoked. | |
| const remainingFaces = extractedFaces.filter(f => !selectedFaceIds.has(f.id)); | |
| const remainingUrls = new Set(remainingFaces.map(f => f.sourceUrl)); | |
| const urlsToRevoke = new Set(); | |
| extractedFaces.forEach(face => { | |
| // If a face is selected for deletion... | |
| if (selectedFaceIds.has(face.id)) { | |
| // ...and its sourceUrl is not used by any *remaining* face... | |
| if (!remainingUrls.has(face.sourceUrl)) { | |
| // ...and it's a blob URL, mark it for revocation. | |
| if (face.sourceUrl && face.sourceUrl.startsWith('blob:')) { | |
| urlsToRevoke.add(face.sourceUrl); | |
| } | |
| } | |
| } | |
| }); | |
| urlsToRevoke.forEach(url => URL.revokeObjectURL(url)); | |
| // Find which standard batch IDs need to be re-enabled for extraction. | |
| // We must iterate over the master list of `extractedFaces` *before* they are filtered and deleted. | |
| const standardIdsToReEnable = new Set(); | |
| extractedFaces.forEach(face => { | |
| if (selectedFaceIds.has(face.id) && face.originalStandardFaceId) { | |
| standardIdsToReEnable.add(face.originalStandardFaceId); | |
| } | |
| }); | |
| if (standardIdsToReEnable.size > 0) { | |
| setExtractedBatchIds(prev => { | |
| const next = new Set(prev); | |
| standardIdsToReEnable.forEach(id => next.delete(id)); | |
| return next; | |
| }); | |
| } | |
| // 1. Remove from visual groups | |
| setFaceGroups(prevGroups => { | |
| return prevGroups.map(group => ({ | |
| ...group, | |
| faces: group.faces.filter(f => !selectedFaceIds.has(f.id)) | |
| })).filter(g => g.faces.length > 0 || g.isStandardGroup || g.isNoFaceGroup); // Keep special groups when empty | |
| }); | |
| // 2. Remove from the base extracted faces array so they don't reappear on re-clustering | |
| setExtractedFaces(prevFaces => prevFaces.filter(f => !selectedFaceIds.has(f.id))); | |
| // 3. Clear selection state and close modal | |
| setSelectedFaceIds(new Set()); | |
| setShowDeleteConfirm(false); | |
| }; | |
| 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) => { | |
| const hasName = group.name && group.name.trim() !== ''; | |
| let folderName = hasName ? group.name.trim() : `Group_${gIndex + 1}`; | |
| let filePrefix = hasName ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : 'image'; | |
| if (group.isNoFaceGroup) { | |
| folderName = "Unrecognized"; | |
| filePrefix = "unrecognized"; | |
| } else if (group.isStandardGroup) { | |
| folderName = hasName ? group.name.trim() : "Batch_Processed"; | |
| filePrefix = (hasName && group.name !== 'Batch Processed') ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : "image"; | |
| } | |
| 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 = `${filePrefix}_${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_Images_${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(); | |
| const hasName = group.name && group.name.trim() !== ''; | |
| let filePrefix = hasName ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : 'image'; | |
| if (group.isNoFaceGroup) filePrefix = "unrecognized"; | |
| else if (group.isStandardGroup) filePrefix = (hasName && group.name !== 'Batch Processed') ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : "image"; | |
| const fileName = `${filePrefix}_${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); | |
| }; | |
| const toggleGroupCollapse = (groupId) => { | |
| setCollapsedGroups(prev => { | |
| const next = new Set(prev); | |
| if (next.has(groupId)) next.delete(groupId); | |
| else next.add(groupId); | |
| return next; | |
| }); | |
| }; | |
| return ( | |
| <div className="min-h-screen font-sans"> | |
| <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"> | |
| {processingMode === 'smart' && ( | |
| <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={() => !isProcessing && fileInputRef.current.click()} disabled={isProcessing} className="text-sm flex items-center gap-2 text-neutral-400 hover:text-white transition-colors disabled:opacity-50 cursor-pointer"> | |
| <UploadCloudIcon size={16} /> Import Images | |
| </button> | |
| )} | |
| {faceGroups.some(g => processingMode === 'smart' ? (!g.isStandardGroup) : g.isStandardGroup) && ( | |
| <button onClick={clearCurrentMode} className="text-sm flex items-center gap-2 text-neutral-400 hover:text-amber-400 transition-colors"> | |
| <Trash2Icon size={16} /> {processingMode === 'smart' ? 'Clear Tracked Faces' : 'Clear Batch Crop'} | |
| </button> | |
| )} | |
| {faceGroups.length > 0 && ( | |
| <button onClick={clearAll} className="text-sm flex items-center gap-2 text-neutral-400 hover:text-red-400 transition-colors border-l border-neutral-700 pl-4"> | |
| <XIcon size={16} /> Start Over Entirely | |
| </button> | |
| )} | |
| </div> | |
| </header> | |
| {/* Dynamic wrapper transitions width allocating max possible area when in standard mode */} | |
| <main className={`mx-auto pb-32 transition-all duration-500 ease-in-out ${processingMode === 'standard' ? 'w-full max-w-none px-4 sm:px-8 lg:px-12 2xl:px-16 pt-6' : 'w-full md:w-[90%] xl:w-[75%] max-w-none p-4 sm:p-6'}`}> | |
| <input type="file" multiple accept="image/*" ref={fileInputRef} onChange={onFileChange} className="hidden" /> | |
| {faceGroups.length === 0 && ( | |
| <div className="mt-12"> | |
| <div className="flex justify-center mb-8"> | |
| <div className="bg-neutral-900 p-1 rounded-xl border border-neutral-800 flex gap-1 shadow-inner"> | |
| <button | |
| onClick={() => handleModeSwitch('smart')} | |
| disabled={isModelLoading || isProcessing} | |
| className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center gap-2 disabled:opacity-50 ${processingMode === 'smart' ? 'bg-indigo-600 text-white shadow-md' : 'text-neutral-400 hover:text-white hover:bg-neutral-800'}`} | |
| > | |
| <UsersIcon size={16} /> Smart Face Track | |
| </button> | |
| <button | |
| onClick={() => handleModeSwitch('standard')} | |
| disabled={isModelLoading || isProcessing} | |
| className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-all flex items-center gap-2 disabled:opacity-50 ${processingMode === 'standard' ? 'bg-indigo-600 text-white shadow-md' : 'text-neutral-400 hover:text-white hover:bg-neutral-800'}`} | |
| > | |
| <SquareIcon size={16} /> Standard Batch Crop | |
| </button> | |
| </div> | |
| </div> | |
| <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'} | |
| `} | |
| > | |
| {(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, or batch crop them instantly.</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"> | |
| {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 group</p> | |
| </div> | |
| )} | |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> | |
| <div className="flex flex-col sm:flex-row sm:items-center gap-6"> | |
| <div> | |
| <h2 className="text-2xl font-semibold tracking-tight"> | |
| {processingMode === 'standard' ? 'Batch Processing' : `Found ${faceGroups.filter(g => !g.isStandardGroup && !g.isNoFaceGroup).length} People`} | |
| </h2> | |
| <div className="text-sm text-neutral-400 mt-1">Total items: {faceGroups.reduce((acc, curr) => acc + curr.faces.length, 0)}</div> | |
| </div> | |
| {/* Mini Mode Toggle attached to active gallery so users can switch modes on the fly */} | |
| <div className="bg-neutral-900 p-1 rounded-lg border border-neutral-800 flex gap-1 shadow-inner self-start sm:self-auto"> | |
| <button | |
| onClick={() => handleModeSwitch('smart')} | |
| disabled={isModelLoading || isProcessing || (processingMode === 'standard' && !faceGroups.some(g => !g.isStandardGroup))} | |
| className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${processingMode === 'smart' ? 'bg-indigo-600 text-white shadow-md' : 'text-neutral-400 hover:text-white hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-400'}`} | |
| > | |
| <UsersIcon size={14} /> Smart Face Track | |
| </button> | |
| <button | |
| onClick={() => handleModeSwitch('standard')} | |
| disabled={isModelLoading || isProcessing} | |
| className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 disabled:opacity-50 ${processingMode === 'standard' ? 'bg-indigo-600 text-white shadow-md' : 'text-neutral-400 hover:text-white hover:bg-neutral-800'}`} | |
| > | |
| <SquareIcon size={14} /> Standard Crop | |
| </button> | |
| </div> | |
| </div> | |
| {processingMode === 'smart' && ( | |
| <div className="flex items-center gap-4"> | |
| {faceGroups.some(g => g.isStandardGroup) && ( | |
| <button onClick={() => setHideStandardInSmart(!hideStandardInSmart)} className={`h-full px-4 py-2 border rounded-xl transition-colors flex flex-col items-center justify-center gap-1 shadow-inner min-w-[100px] ${!hideStandardInSmart ? 'bg-indigo-600/20 border-indigo-500/50 text-indigo-400' : 'bg-neutral-900/80 border-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-800'}`}> | |
| <SquareIcon size={18}/> | |
| <span className="text-[10px] font-bold uppercase tracking-wider">{!hideStandardInSmart ? "Hide Batch" : "Show Batch"}</span> | |
| </button> | |
| )} | |
| <button onClick={toggleAllSmartGroups} className="h-full px-4 py-2 bg-neutral-900/80 border border-neutral-800 rounded-xl text-neutral-400 hover:text-white hover:bg-neutral-800 transition-colors flex flex-col items-center justify-center gap-1 shadow-inner min-w-[100px]"> | |
| {allSmartCollapsed ? <EyeIcon size={18}/> : <EyeOffIcon size={18}/>} | |
| <span className="text-[10px] font-bold uppercase tracking-wider">{allSmartCollapsed ? "Show All" : "Hide All"}</span> | |
| </button> | |
| <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> | |
| {(() => { | |
| const standardGroup = faceGroups.find(g => g.isStandardGroup); | |
| const masonryGroups = processingMode === 'smart' ? faceGroups.filter(g => !g.isStandardGroup) : []; | |
| return ( | |
| <div className={`flex flex-col ${processingMode === 'standard' && standardGroup ? 'xl:flex-row gap-8 items-start' : 'gap-6'}`}> | |
| {/* Main Left Gallery Area */} | |
| <div className="flex-1 min-w-0 w-full space-y-8"> | |
| {/* --- LARGE FLAT GRID FOR STANDARD CROP OR MINIMIZED IN SMART MODE --- */} | |
| {standardGroup && (processingMode === 'standard' || (processingMode === 'smart' && !hideStandardInSmart)) && ( | |
| <div key={standardGroup.id} className={`break-inside-avoid bg-neutral-900 rounded-3xl border transition-colors overflow-hidden group ${dragOverGroupId === standardGroup.id ? 'border-indigo-500 shadow-[0_0_15px_rgba(99,102,241,0.3)]' : 'border-neutral-800'}`} onDragOver={(e) => handleDragOver(e, standardGroup.id)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, standardGroup.id)}> | |
| <div className="px-6 py-5 flex items-center justify-between border-b border-neutral-800/50 bg-neutral-900/80"> | |
| <div className="flex items-center gap-4"> | |
| <div className="w-12 h-12 rounded-xl bg-indigo-500/10 flex items-center justify-center border border-indigo-500/20 text-indigo-400 shadow-inner"> | |
| <SquareIcon size={24} /> | |
| </div> | |
| <div className="flex flex-col"> | |
| <div className="text-lg font-bold text-white tracking-wide">Batch Processed Gallery</div> | |
| <div className="text-xs text-neutral-400 mt-0.5">{standardGroup.faces.length} full-size images</div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {processingMode === 'standard' && ( | |
| (() => { | |
| const candidates = selectedFaceIds.size > 0 ? standardGroup.faces.filter(f => selectedFaceIds.has(f.id)) : standardGroup.faces; | |
| const allExtracted = candidates.length > 0 && candidates.every(f => extractedBatchIds.has(f.id)); | |
| return ( | |
| <button onClick={exportToSmartTrack} disabled={isProcessing || allExtracted} className={`transition-all p-1.5 sm:px-3 sm:py-1.5 flex items-center gap-1.5 text-sm rounded-lg font-bold disabled:cursor-not-allowed ${allExtracted ? 'bg-transparent border border-neutral-700 text-neutral-600 opacity-50' : 'bg-indigo-600 text-white hover:bg-indigo-500 shadow-md'}`} title="Extract faces from these cropped images"> | |
| <UsersIcon size={16} /> | |
| <span className="font-medium hidden sm:inline">{allExtracted ? 'Faces Extracted' : 'Extract Faces'}</span> | |
| </button> | |
| ); | |
| })() | |
| )} | |
| {processingMode === 'standard' && <div className="w-px h-6 bg-neutral-700 mx-1"></div>} | |
| <button onClick={() => toggleGroupCollapse(standardGroup.id)} className="text-neutral-400 hover:text-white transition-colors p-2 flex items-center gap-1.5 text-sm bg-neutral-800/60 hover:bg-neutral-700 rounded-lg" title={collapsedGroups.has(standardGroup.id) ? "Show images" : "Hide images"}> | |
| {collapsedGroups.has(standardGroup.id) ? <EyeIcon size={16} /> : <EyeOffIcon size={16} />} | |
| <span className="font-medium hidden sm:inline">{collapsedGroups.has(standardGroup.id) ? "Show" : "Hide"}</span> | |
| </button> | |
| <div className="w-px h-6 bg-neutral-700 mx-1"></div> | |
| <button onClick={() => toggleGroup(standardGroup)} className="text-neutral-400 hover:text-white transition-colors p-1.5 flex items-center gap-2 text-sm" title={standardGroup.faces.every(f => selectedFaceIds.has(f.id)) ? "Deselect All" : "Select All"}> | |
| {standardGroup.faces.every(f => selectedFaceIds.has(f.id)) ? <CheckSquareIcon size={24} className="text-indigo-500" /> : (!standardGroup.faces.every(f => selectedFaceIds.has(f.id)) && standardGroup.faces.some(f => selectedFaceIds.has(f.id))) ? <CheckSquareIcon size={24} className="text-indigo-500 opacity-50" /> : <SquareIcon size={24} />} | |
| </button> | |
| </div> | |
| </div> | |
| {!collapsedGroups.has(standardGroup.id) && ( | |
| <div className="p-6 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 min-[1920px]:grid-cols-7 gap-6 sm:gap-8 max-h-[80vh] overflow-y-auto immager-scrollbar bg-neutral-950/40"> | |
| {standardGroup.faces.map((face, fIndex) => { | |
| const isSelected = selectedFaceIds.has(face.id); | |
| return ( | |
| <div key={face.id} className={`relative overflow-visible group/item transition-all duration-300 transform ${isSelected ? 'ring-2 ring-indigo-500 ring-offset-4 ring-offset-neutral-950 scale-[0.98] shadow-[0_0_25px_rgba(99,102,241,0.2)] rounded-2xl' : 'ring-1 ring-neutral-700/50 hover:ring-neutral-500 hover:-translate-y-1 hover:shadow-2xl rounded-2xl'}`}> | |
| {/* New Full Size Inline Cropper completely free of 512px limits */} | |
| <InlineCropper face={face} onSaveBox={saveStandardCropBox} standardShape={cropSettings.standardShape} /> | |
| <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/30 opacity-0 group-hover/item:opacity-100 transition-opacity duration-300 pointer-events-none rounded-2xl"></div> | |
| {isSelected && <div className="absolute inset-0 bg-indigo-500/20 pointer-events-none border-2 border-indigo-500/50 rounded-2xl z-20"></div>} | |
| {/* Safe Drag Handle for Standard Items */} | |
| <div draggable={true} onDragStart={(e) => handleDragStart(e, face.id, standardGroup.id)} onDragEnd={handleDragEnd} className="absolute top-3 left-12 z-40 cursor-grab active:cursor-grabbing opacity-0 group-hover/item:opacity-100 transition-opacity bg-black/60 rounded-full w-8 h-8 flex items-center justify-center border border-white/20 hover:bg-black/80 hover:text-white text-neutral-300" title="Drag to Move"> | |
| <MoveIcon size={16} /> | |
| </div> | |
| {/* FIX: Moved toggleFace strictly to the checkmark badge so dragging crop box doesn't select the image */} | |
| <div | |
| onClick={(e) => { e.stopPropagation(); toggleFace(face.id); }} | |
| className={`absolute top-3 left-3 z-30 cursor-pointer transition-all duration-300 ${isSelected ? 'opacity-100 scale-100' : 'opacity-60 scale-90 group-hover/item:opacity-100 group-hover/item:scale-100 hover:scale-110'}`}> | |
| {isSelected ? ( | |
| <div className="bg-indigo-500 rounded-full w-8 h-8 flex items-center justify-center text-white shadow-xl"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg> | |
| </div> | |
| ) : ( | |
| <div className="bg-black/60 border-2 border-white/70 rounded-full w-8 h-8 backdrop-blur-md transition-colors hover:bg-black/80 shadow-lg flex items-center justify-center"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-0 group-hover/item:opacity-50"><polyline points="20 6 9 17 4 12"></polyline></svg> | |
| </div> | |
| )} | |
| </div> | |
| <div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-all duration-300 z-30 flex items-center bg-black/60 backdrop-blur-md rounded-xl border border-white/10 shadow-2xl overflow-hidden p-1"> | |
| {/* Manual Crop Button (EditIcon) completely eliminated for Batch Crop items as requested */} | |
| <button onClick={(e) => { e.stopPropagation(); window.addToClipboard(face.cropDataUrl, `immager_batch_${fIndex+1}.jpg`); }} className="p-2 text-neutral-300 hover:text-amber-400 hover:bg-white/10 rounded-lg transition-colors" title="Save to Clipboard"> | |
| <ClipboardIcon size={18} /> | |
| </button> | |
| <div className="w-px h-5 bg-white/20 mx-0.5"></div> | |
| <button onClick={(e) => { e.stopPropagation(); downloadSingleFace(e, face, standardGroup, fIndex); }} className="p-2 text-neutral-300 hover:text-emerald-400 hover:bg-white/10 rounded-lg transition-colors" title="Download Image"> | |
| <DownloadIcon size={18} /> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* --- MASONRY COLUMNS FOR SMART TRACKING --- */} | |
| {processingMode === 'smart' && masonryGroups.length > 0 && ( | |
| <div className="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6"> | |
| {masonryGroups.map((group) => { | |
| const groupIndex = faceGroups.findIndex(g => g.id === group.id); | |
| const allSelected = group.faces.every(f => selectedFaceIds.has(f.id)); | |
| const someSelected = !allSelected && group.faces.some(f => selectedFaceIds.has(f.id)); | |
| const isCollapsed = collapsedGroups.has(group.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-10 h-10 rounded-full overflow-hidden border border-neutral-700 shadow-inner bg-neutral-800 flex items-center justify-center"> | |
| {group.faces.length > 0 ? ( | |
| <img src={group.faces[0].cropDataUrl} className="w-full h-full object-cover pointer-events-none" alt={`Person ${groupIndex + 1}`} /> | |
| ) : ( | |
| <UsersIcon size={20} className="text-neutral-500" /> | |
| )} | |
| </div> | |
| <div className="flex flex-col"> | |
| {group.isNoFaceGroup ? ( | |
| <div className="text-sm font-bold text-amber-500 w-32 truncate" title="No Face Detected">Unrecognized</div> | |
| ) : group.isStandardGroup ? ( | |
| <div className="text-sm font-bold text-white w-32 truncate" title="Batch Processed">Batch Processed</div> | |
| ) : ( | |
| <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> | |
| <div className="flex items-center gap-1.5"> | |
| <button onClick={() => toggleGroupCollapse(group.id)} className="text-neutral-400 hover:text-white transition-colors p-1.5 flex items-center gap-1.5 text-xs bg-neutral-800/60 hover:bg-neutral-700 rounded-md" title={isCollapsed ? "Show images" : "Hide images"}> | |
| {isCollapsed ? <EyeIcon size={14} /> : <EyeOffIcon size={14} />} | |
| <span className="font-medium hidden sm:inline">{isCollapsed ? "Show" : "Hide"}</span> | |
| </button> | |
| <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> | |
| {!isCollapsed && ( | |
| <div className="p-4 grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-h-[55vh] overflow-y-auto immager-scrollbar"> | |
| {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-2xl overflow-hidden cursor-grab active:cursor-grabbing group/item transition-all duration-300 transform ${isSelected ? 'ring-2 ring-indigo-500 ring-offset-2 ring-offset-neutral-900 scale-[0.92] shadow-[0_0_20px_rgba(99,102,241,0.3)]' : 'hover:-translate-y-1 hover:shadow-xl hover:ring-1 hover:ring-neutral-700'}`}> | |
| <img src={face.cropDataUrl} alt="Crop" draggable="false" className="w-full h-full object-cover select-none pointer-events-none transition-transform duration-500 group-hover/item:scale-105" loading="lazy" /> | |
| {/* Gradient Overlay for modern contrast */} | |
| <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/30 opacity-0 group-hover/item:opacity-100 transition-opacity duration-300 pointer-events-none"></div> | |
| {/* Selected State Overlay Wash */} | |
| {isSelected && <div className="absolute inset-0 bg-indigo-500/20 pointer-events-none"></div>} | |
| {/* Modern Selection Badge (Top Left) */} | |
| <div className={`absolute top-3 left-3 z-10 transition-all duration-300 ${isSelected ? 'opacity-100 scale-100' : 'opacity-0 scale-90 group-hover/item:opacity-100 group-hover/item:scale-100'}`}> | |
| {isSelected ? ( | |
| <div className="bg-indigo-500 rounded-full w-7 h-7 flex items-center justify-center text-white shadow-lg"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg> | |
| </div> | |
| ) : ( | |
| <div className="bg-black/40 border-2 border-white/70 rounded-full w-7 h-7 backdrop-blur-md transition-colors hover:bg-black/60"></div> | |
| )} | |
| </div> | |
| {/* Refined Action Buttons Pill (Top Right) */} | |
| <div className="absolute top-3 right-3 opacity-0 group-hover/item:opacity-100 transition-all duration-300 z-10 flex items-center bg-black/50 backdrop-blur-md rounded-xl border border-white/10 shadow-xl overflow-hidden p-0.5"> | |
| <button onClick={(e) => { e.stopPropagation(); openEditor(face, groupIndex, fIndex); }} className="p-1.5 text-neutral-300 hover:text-indigo-400 hover:bg-white/10 rounded-lg transition-colors" title="Manual Crop"> | |
| <EditIcon size={16} /> | |
| </button> | |
| <div className="w-px h-4 bg-white/20 mx-0.5"></div> | |
| <button onClick={(e) => { e.stopPropagation(); window.addToClipboard(face.cropDataUrl, `immager_${group.name || 'face'}_${fIndex+1}.jpg`); }} className="p-1.5 text-neutral-300 hover:text-amber-400 hover:bg-white/10 rounded-lg transition-colors" title="Save to Clipboard"> | |
| <ClipboardIcon size={16} /> | |
| </button> | |
| <div className="w-px h-4 bg-white/20 mx-0.5"></div> | |
| <button onClick={(e) => { e.stopPropagation(); downloadSingleFace(e, face, group, fIndex); }} className="p-1.5 text-neutral-300 hover:text-emerald-400 hover:bg-white/10 rounded-lg transition-colors" title="Download Image"> | |
| <DownloadIcon size={16} /> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| {/* --- RIGHT SIDE PANEL FOR BULK CONSTRAINTS --- */} | |
| {processingMode === 'standard' && standardGroup && ( | |
| <div className="w-full xl:w-[320px] flex-shrink-0 sticky top-28 space-y-6 z-20"> | |
| <div className="bg-neutral-900 border border-neutral-800 rounded-3xl p-6 shadow-2xl"> | |
| <h3 className="text-lg font-bold text-white mb-5 flex items-center gap-2"> | |
| <SettingsIcon size={20} className="text-indigo-400" /> Bulk Constraints | |
| </h3> | |
| <div className="space-y-5"> | |
| <div> | |
| <label className="block text-sm font-medium text-neutral-400 mb-2">Crop Aspect Ratio</label> | |
| <select | |
| disabled={isProcessing} | |
| value={cropSettings.standardShape} | |
| onChange={(e) => applyGlobalCropSettings({...cropSettings, standardShape: e.target.value})} | |
| className="w-full bg-neutral-950 border border-neutral-700 rounded-xl p-3 text-sm text-white outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-all cursor-pointer" | |
| > | |
| <option value="free">Free Form (Unconstrained)</option> | |
| <option value="square">1:1 Square</option> | |
| <option value="4:3">4:3 Landscape</option> | |
| <option value="16:9">16:9 Widescreen</option> | |
| <option value="9:16">9:16 Portrait</option> | |
| <option value="original">Original Aspect Ratio</option> | |
| </select> | |
| </div> | |
| {/* Export Resolution Engine */} | |
| <div className="mt-6 pt-5 border-t border-neutral-800/60"> | |
| <label className="block text-sm font-medium text-neutral-400 mb-3">Export Resolution <span className="text-[10px] text-neutral-500 font-normal">(Pixels)</span></label> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex-1 space-y-2 relative"> | |
| <div className="flex justify-between items-center"> | |
| <span className="text-[10px] text-neutral-500 uppercase font-bold tracking-wider">Width</span> | |
| <label className="text-[10px] text-indigo-400 flex items-center gap-1.5 cursor-pointer font-medium hover:text-indigo-300"> | |
| <input type="checkbox" checked={cropSettings.batchWidth === 'auto'} onChange={(e) => applyGlobalCropSettings({...cropSettings, batchWidth: e.target.checked ? 'auto' : 512})} className="accent-indigo-500 cursor-pointer w-3 h-3 rounded-sm" /> Auto | |
| </label> | |
| </div> | |
| <div className="relative"> | |
| <input key={`w-${cropSettings.batchWidth}`} type="number" disabled={isProcessing || cropSettings.batchWidth === 'auto'} defaultValue={cropSettings.batchWidth === 'auto' ? '' : cropSettings.batchWidth} onBlur={(e) => applyGlobalCropSettings({...cropSettings, batchWidth: e.target.value ? Math.max(1, parseInt(e.target.value)) : 'auto'})} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} placeholder="Auto" className="w-full bg-neutral-950 border border-neutral-700 rounded-xl p-2.5 text-sm text-white outline-none focus:border-indigo-500 disabled:opacity-30 disabled:cursor-not-allowed font-mono transition-all pr-8" /> | |
| <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-neutral-600 pointer-events-none">px</span> | |
| </div> | |
| </div> | |
| <div className="flex items-center justify-center pt-6 text-neutral-600"> | |
| <XIcon size={14} /> | |
| </div> | |
| <div className="flex-1 space-y-2 relative"> | |
| <div className="flex justify-between items-center"> | |
| <span className="text-[10px] text-neutral-500 uppercase font-bold tracking-wider">Height</span> | |
| <label className="text-[10px] text-indigo-400 flex items-center gap-1.5 cursor-pointer font-medium hover:text-indigo-300"> | |
| <input type="checkbox" checked={cropSettings.batchHeight === 'auto'} onChange={(e) => applyGlobalCropSettings({...cropSettings, batchHeight: e.target.checked ? 'auto' : 512})} className="accent-indigo-500 cursor-pointer w-3 h-3 rounded-sm" /> Auto | |
| </label> | |
| </div> | |
| <div className="relative"> | |
| <input key={`h-${cropSettings.batchHeight}`} type="number" disabled={isProcessing || cropSettings.batchHeight === 'auto'} defaultValue={cropSettings.batchHeight === 'auto' ? '' : cropSettings.batchHeight} onBlur={(e) => applyGlobalCropSettings({...cropSettings, batchHeight: e.target.value ? Math.max(1, parseInt(e.target.value)) : 'auto'})} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} placeholder="Auto" className="w-full bg-neutral-950 border border-neutral-700 rounded-xl p-2.5 text-sm text-white outline-none focus:border-indigo-500 disabled:opacity-30 disabled:cursor-not-allowed font-mono transition-all pr-8" /> | |
| <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-neutral-600 pointer-events-none">px</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="bg-indigo-500/10 border border-indigo-500/20 rounded-xl p-4 mt-2"> | |
| <p className="text-xs text-indigo-300 leading-relaxed"> | |
| Settings apply instantly to all batch items. Type specific pixels and click <kbd className="px-1 py-0.5 border border-indigo-400/30 rounded text-[10px]">Enter</kbd> to force output size. Use "Auto" to dynamically calculate from aspect ratio. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| )} | |
| </main> | |
| {faceGroups.length > 0 && ( | |
| <div className={`fixed bottom-20 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-3"> | |
| <div className="font-medium text-sm mr-2"><span className="text-indigo-400 font-bold">{selectedFaceIds.size}</span> items selected</div> | |
| <button onClick={() => setShowDeleteConfirm(true)} disabled={isProcessing} className={`flex items-center gap-2 bg-red-500/10 text-red-500 border border-red-500/20 px-4 py-2.5 rounded-full text-sm font-medium hover:bg-red-600 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}> | |
| <Trash2Icon size={18} /> | |
| Remove | |
| </button> | |
| <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> | |
| )} | |
| {showDeleteConfirm && ( | |
| <div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4"> | |
| <div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 w-full max-w-sm shadow-2xl animate-in flex flex-col items-center text-center"> | |
| <div className="w-16 h-16 bg-red-500/10 text-red-500 rounded-full flex items-center justify-center mb-4"> | |
| <AlertTriangleIcon size={32} /> | |
| </div> | |
| <h3 className="text-xl font-bold text-white mb-2">Remove Items?</h3> | |
| <p className="text-sm text-neutral-400 mb-6"> | |
| Are you sure you want to remove <span className="text-white font-bold">{selectedFaceIds.size}</span> selected item{selectedFaceIds.size > 1 ? 's' : ''}? This action cannot be undone. | |
| </p> | |
| <div className="w-full flex gap-3"> | |
| <button onClick={() => setShowDeleteConfirm(false)} className="flex-1 py-2.5 rounded-xl border border-neutral-700 text-white font-medium hover:bg-neutral-800 transition-colors">Cancel</button> | |
| <button onClick={confirmDelete} className="flex-1 py-2.5 rounded-xl bg-red-600 hover:bg-red-500 text-white font-medium transition-colors shadow-lg shadow-red-600/20">Yes, Remove</button> | |
| </div> | |
| </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">{processingMode === 'standard' ? 'Batch Crop Settings' : '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"> | |
| {processingMode === 'smart' && ( | |
| <div> | |
| <label className="block text-sm text-neutral-400 mb-2">Padding Style <span className="text-[10px] text-neutral-500 ml-2">(Face Tracking Only)</span></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 items.</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; 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> | |
| ); | |
| } | |
| const root = ReactDOM.createRoot(document.getElementById('root')); | |
| root.render(<FaceExtractApp />); | |
| </script> | |
| <!-- ========================================== --> | |
| <!-- LOGIC: APP 2 (VIDEOFLOW - MODULE) --> | |
| <!-- ========================================== --> | |
| <script type="module"> | |
| import { ObjectDetector, FilesetResolver } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/vision_bundle.mjs"; | |
| const videoInput = document.getElementById('video-input'); | |
| const dropZone = document.getElementById('drop-zone'); | |
| const videoEl = document.getElementById('hidden-video'); | |
| const previewCanvas = document.getElementById('preview-canvas'); | |
| const resultsEl = document.getElementById('results'); | |
| const progressBar = document.getElementById('progress-bar'); | |
| const progressPercent = document.getElementById('progress-percent'); | |
| const statusLabel = document.getElementById('status-label'); | |
| const startBtn = document.getElementById('start-btn'); | |
| const downloadBtn = document.getElementById('download-btn'); | |
| const exportImmagerBtn = document.getElementById('export-immager-btn'); | |
| const selectAllBtn = document.getElementById('select-all-btn'); | |
| const modelBadge = document.getElementById('model-badge'); | |
| const faceBadge = document.getElementById('face-badge'); | |
| const settingsPanel = document.getElementById('settings-panel'); | |
| const statsText = document.getElementById('stats-text'); | |
| const scannerLine = document.getElementById('scanner'); | |
| const emptyState = document.getElementById('empty-state'); | |
| const extractAllToggle = document.getElementById('extract-all-toggle'); | |
| const advancedFiltersContainer = document.getElementById('advanced-filters-container'); | |
| const autoCropToggle = document.getElementById('auto-crop-toggle'); | |
| const faceCropToggle = document.getElementById('face-crop-toggle'); | |
| const requireFaceToggle = document.getElementById('require-face-toggle'); | |
| const faceUploadBtn = document.getElementById('face-upload-btn'); | |
| const faceInput = document.getElementById('face-input'); | |
| const faceStatusText = document.getElementById('face-status-text'); | |
| const targetFacesContainer = document.getElementById('target-faces-container'); | |
| let detector; | |
| let extractedFrames = []; | |
| let isProcessing = false; | |
| window.targetFaces = []; | |
| let isFaceApiLoaded = false; | |
| // Setup frame selection memory globally for Vanilla JS | |
| window.selectedFrames = new Set(); | |
| document.getElementById('scan-rate').oninput = (e) => document.getElementById('interval-val').innerText = e.target.value + 's'; | |
| document.getElementById('confidence').oninput = (e) => document.getElementById('conf-val').innerText = Math.round(e.target.value * 100) + '%'; | |
| // Grey out options logic | |
| extractAllToggle.addEventListener('change', (e) => { | |
| if(e.target.checked) { | |
| advancedFiltersContainer.style.opacity = '0.3'; | |
| advancedFiltersContainer.style.pointerEvents = 'none'; | |
| } else { | |
| advancedFiltersContainer.style.opacity = '1'; | |
| advancedFiltersContainer.style.pointerEvents = 'auto'; | |
| } | |
| }); | |
| async function initMediaPipe() { | |
| try { | |
| const vision = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"); | |
| detector = await ObjectDetector.createFromOptions(vision, { | |
| baseOptions: { | |
| modelAssetPath: `https://storage.googleapis.com/mediapipe-models/object_detector/efficientdet_lite0/float16/1/efficientdet_lite0.tflite`, | |
| delegate: "CPU" | |
| }, | |
| scoreThreshold: 0.5, | |
| runningMode: "IMAGE" | |
| }); | |
| modelBadge.innerHTML = '<span class="status-dot bg-emerald-500"></span> MediaPipe Ready'; | |
| modelBadge.classList.replace('text-slate-400', 'text-emerald-400'); | |
| } catch (err) { | |
| console.error("MediaPipe Error:", err); | |
| modelBadge.innerHTML = '<span class="status-dot bg-red-500"></span> Engine Error'; | |
| } | |
| } | |
| async function initFaceAPI() { | |
| faceBadge.classList.remove('hidden'); | |
| try { | |
| const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.12/model/'; | |
| await Promise.all([ | |
| window.faceapi.nets.ssdMobilenetv1.loadFromUri(MODEL_URL), | |
| window.faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL), | |
| window.faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL) | |
| ]); | |
| isFaceApiLoaded = true; | |
| faceBadge.innerHTML = '<span class="status-dot bg-purple-500"></span> FaceAPI Ready'; | |
| faceBadge.classList.replace('text-slate-400', 'text-purple-400'); | |
| } catch(err) { | |
| console.error("FaceAPI Error:", err); | |
| faceBadge.innerHTML = '<span class="status-dot bg-red-500"></span> FaceAPI Error'; | |
| } | |
| } | |
| initMediaPipe(); | |
| setTimeout(initFaceAPI, 500); | |
| window.removeTargetFace = function(index) { | |
| window.targetFaces.splice(index, 1); | |
| renderTargetFaces(); | |
| }; | |
| function renderTargetFaces() { | |
| targetFacesContainer.innerHTML = ''; | |
| window.targetFaces.forEach((face, index) => { | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = 'relative inline-block'; | |
| wrapper.innerHTML = ` | |
| <img src="${face.preview}" class="w-16 h-16 object-cover rounded-full border-2 border-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]"> | |
| <button class="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600 shadow transition-colors" onclick="window.removeTargetFace(${index})"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| `; | |
| targetFacesContainer.appendChild(wrapper); | |
| }); | |
| if(window.targetFaces.length > 0) { | |
| faceUploadBtn.innerHTML = '<i class="fas fa-plus mr-1"></i> Add More Faces'; | |
| faceStatusText.classList.remove('hidden'); | |
| faceStatusText.innerHTML = `<i class="fas fa-check text-emerald-400"></i> ${window.targetFaces.length} Face(s) Locked`; | |
| faceStatusText.className = "text-[10px] text-emerald-400 mt-2 text-center font-bold"; | |
| } else { | |
| faceUploadBtn.innerHTML = '<i class="fas fa-camera mr-1"></i> Upload Target Face'; | |
| faceStatusText.classList.add('hidden'); | |
| } | |
| } | |
| faceUploadBtn.onclick = () => faceInput.click(); | |
| faceInput.onchange = async (e) => { | |
| const files = Array.from(e.target.files); | |
| if (files.length === 0) return; | |
| if (!isFaceApiLoaded) { | |
| alert("Please wait for FaceAPI to finish loading..."); | |
| return; | |
| } | |
| faceStatusText.classList.remove('hidden'); | |
| faceStatusText.innerHTML = '<i class="fas fa-spinner animate-spin"></i> Analyzing face(s)...'; | |
| faceStatusText.className = "text-[10px] text-amber-400 mt-2 text-center"; | |
| for (let file of files) { | |
| try { | |
| const url = URL.createObjectURL(file); | |
| const img = new Image(); | |
| await new Promise(r => { img.onload = r; img.src = url; }); | |
| const options = new window.faceapi.SsdMobilenetv1Options({ minConfidence: 0.2 }); | |
| const detections = await window.faceapi.detectAllFaces(img, options).withFaceLandmarks().withFaceDescriptors(); | |
| if (detections.length > 0) { | |
| detections.forEach(det => { | |
| const box = det.detection.box; | |
| const canvas = document.createElement('canvas'); | |
| const pad = Math.max(box.width, box.height) * 0.3; | |
| const size = Math.max(box.width, box.height) + pad * 2; | |
| canvas.width = size; | |
| canvas.height = size; | |
| const ctx = canvas.getContext('2d'); | |
| const sx = box.x - pad; | |
| const sy = box.y - pad; | |
| ctx.drawImage(img, sx, sy, size, size, 0, 0, size, size); | |
| window.targetFaces.push({ | |
| descriptor: det.descriptor, | |
| preview: canvas.toDataURL('image/jpeg', 0.8) | |
| }); | |
| }); | |
| } | |
| } catch (err) { | |
| console.error("Detection error:", err); | |
| } | |
| } | |
| if (window.targetFaces.length === 0) { | |
| faceStatusText.innerHTML = '<i class="fas fa-times text-red-500"></i> No faces detected.'; | |
| faceStatusText.className = "text-[10px] text-red-500 mt-2 text-center"; | |
| } else { | |
| renderTargetFaces(); | |
| } | |
| faceInput.value = ''; // Reset input to allow re-uploading the same file if needed | |
| }; | |
| dropZone.onclick = () => videoInput.click(); | |
| videoInput.onchange = (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| settingsPanel.style.opacity = "1"; | |
| settingsPanel.style.pointerEvents = "all"; | |
| dropZone.querySelector('h3').innerText = file.name; | |
| statsText.innerText = "Video loaded. Ready to scan."; | |
| } | |
| }; | |
| startBtn.onclick = async () => { | |
| if (isProcessing) { | |
| isProcessing = false; | |
| return; | |
| } | |
| const file = videoInput.files[0]; | |
| if (!file || !detector) return; | |
| isProcessing = true; | |
| extractedFrames = []; | |
| window.selectedFrames.clear(); // Reset selections | |
| updateActionButtons(); | |
| resultsEl.innerHTML = ''; | |
| if (emptyState) emptyState.classList.add('hidden'); | |
| document.getElementById('progress-container').classList.remove('hidden'); | |
| if (scannerLine) scannerLine.style.display = "block"; | |
| startBtn.innerHTML = '<i class="fas fa-stop"></i> Stop Analysis'; | |
| startBtn.classList.replace('bg-indigo-600', 'bg-red-600'); | |
| const url = URL.createObjectURL(file); | |
| videoEl.src = url; | |
| videoEl.onloadedmetadata = async () => { | |
| const ctx = previewCanvas.getContext('2d'); | |
| previewCanvas.width = videoEl.videoWidth; | |
| previewCanvas.height = videoEl.videoHeight; | |
| const duration = videoEl.duration; | |
| const step = parseFloat(document.getElementById('scan-rate').value); | |
| const confidence = parseFloat(document.getElementById('confidence').value); | |
| const extractAll = extractAllToggle.checked; | |
| const doAutoCrop = autoCropToggle.checked; | |
| const doFaceCrop = faceCropToggle.checked; | |
| const requireFace = requireFaceToggle.checked; | |
| const matchFace = window.targetFaces.length > 0; | |
| let lastTime = performance.now(); | |
| for (let time = 0; time < duration; time += step) { | |
| if (!isProcessing) break; | |
| videoEl.currentTime = time; | |
| await new Promise(r => videoEl.onseeked = r); | |
| ctx.drawImage(videoEl, 0, 0); | |
| if (extractAll) { | |
| // Skip detection entirely and just capture the full frame | |
| const fullFrameData = previewCanvas.toDataURL('image/jpeg', 0.9); | |
| extractedFrames.push({ data: fullFrameData, time: time, type: 'Full' }); | |
| addFrameToUI(fullFrameData, time, 'Full', extractedFrames.length - 1); | |
| } else { | |
| detector.setOptions({ scoreThreshold: confidence }); | |
| const results = detector.detect(previewCanvas); | |
| let people = results.detections.filter(d => | |
| d.categories.some(c => c.categoryName === 'person') | |
| ); | |
| let validEntities = []; | |
| if (people.length > 0) { | |
| if (matchFace || requireFace || doFaceCrop) { | |
| const faces = await window.faceapi.detectAllFaces(previewCanvas, new window.faceapi.SsdMobilenetv1Options({minConfidence: 0.3})).withFaceLandmarks().withFaceDescriptors(); | |
| if (matchFace) { | |
| const matchingFaces = faces.filter(f => window.targetFaces.some(tf => window.faceapi.euclideanDistance(tf.descriptor, f.descriptor) < 0.55)); | |
| matchingFaces.forEach(f => { | |
| let linkedPerson = people.find(p => isFaceInBody(f.detection.box, p.boundingBox)); | |
| validEntities.push({ personBox: linkedPerson ? linkedPerson.boundingBox : null, faceBox: f.detection.box, isMatch: true }); | |
| }); | |
| } else if (requireFace) { | |
| faces.forEach(f => { | |
| let linkedPerson = people.find(p => isFaceInBody(f.detection.box, p.boundingBox)); | |
| validEntities.push({ personBox: linkedPerson ? linkedPerson.boundingBox : null, faceBox: f.detection.box, isMatch: false }); | |
| }); | |
| } else { | |
| people.forEach(p => { | |
| let linkedFace = faces.find(f => isFaceInBody(f.detection.box, p.boundingBox)); | |
| validEntities.push({ personBox: p.boundingBox, faceBox: linkedFace ? linkedFace.detection.box : null, isMatch: false }); | |
| }); | |
| } | |
| } else { | |
| people.forEach(p => validEntities.push({ personBox: p.boundingBox, faceBox: null, isMatch: false })); | |
| } | |
| } | |
| if (validEntities.length > 0) { | |
| if (!doAutoCrop && !doFaceCrop) { | |
| const fullFrameData = previewCanvas.toDataURL('image/jpeg', 0.9); | |
| extractedFrames.push({ data: fullFrameData, time: time, type: 'Full' }); | |
| addFrameToUI(fullFrameData, time, 'Full', extractedFrames.length - 1); | |
| } else { | |
| validEntities.forEach(entity => { | |
| if (doAutoCrop && entity.personBox) { | |
| const box = entity.personBox; | |
| const padX = box.width * 0.15; | |
| const padY = box.height * 0.15; | |
| const cX = Math.max(0, box.originX - padX); | |
| const cY = Math.max(0, box.originY - padY); | |
| const cW = Math.min(previewCanvas.width - cX, box.width + padX * 2); | |
| const cH = Math.min(previewCanvas.height - cY, box.height + padY * 2); | |
| const cropCanvas = document.createElement('canvas'); | |
| cropCanvas.width = cW; | |
| cropCanvas.height = cH; | |
| cropCanvas.getContext('2d').drawImage(previewCanvas, cX, cY, cW, cH, 0, 0, cW, cH); | |
| const frameData = cropCanvas.toDataURL('image/jpeg', 0.9); | |
| extractedFrames.push({ data: frameData, time: time, type: 'Body' }); | |
| addFrameToUI(frameData, time, 'Body', extractedFrames.length - 1); | |
| } | |
| if (doFaceCrop && entity.faceBox) { | |
| const fBox = entity.faceBox; | |
| const size = Math.max(fBox.width, fBox.height) * 2.0; | |
| const centerX = fBox.x + fBox.width / 2; | |
| const centerY = fBox.y + fBox.height / 2; | |
| const sX = Math.max(0, centerX - size / 2); | |
| const sY = Math.max(0, centerY - size / 2); | |
| const sW = Math.min(previewCanvas.width - sX, size); | |
| const sH = Math.min(previewCanvas.height - sY, size); | |
| const faceCanvas = document.createElement('canvas'); | |
| faceCanvas.width = 512; | |
| faceCanvas.height = 512; | |
| const fCtx = faceCanvas.getContext('2d'); | |
| fCtx.fillStyle = '#000000'; | |
| fCtx.fillRect(0, 0, 512, 512); | |
| const scale = 512 / size; | |
| const dX = (sX - (centerX - size/2)) * scale; | |
| const dY = (sY - (centerY - size/2)) * scale; | |
| const dW = sW * scale; | |
| const dH = sH * scale; | |
| fCtx.drawImage(previewCanvas, sX, sY, sW, sH, dX, dY, dW, dH); | |
| const faceData = faceCanvas.toDataURL('image/jpeg', 0.95); | |
| extractedFrames.push({ data: faceData, time: time, type: 'Face' }); | |
| addFrameToUI(faceData, time, 'Face', extractedFrames.length - 1); | |
| } | |
| }); | |
| } | |
| validEntities.forEach(entity => { | |
| if (entity.personBox) { | |
| ctx.strokeStyle = entity.isMatch ? '#c084fc' : '#818cf8'; | |
| ctx.lineWidth = 4; | |
| ctx.strokeRect(entity.personBox.originX, entity.personBox.originY, entity.personBox.width, entity.personBox.height); | |
| if (entity.isMatch) { | |
| ctx.fillStyle = '#c084fc'; | |
| ctx.font = '20px Arial'; | |
| ctx.fillText("TARGET MATCH", entity.personBox.originX, entity.personBox.originY - 10); | |
| } | |
| } | |
| if (entity.faceBox) { | |
| ctx.strokeStyle = '#34d399'; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(entity.faceBox.x, entity.faceBox.y, entity.faceBox.width, entity.faceBox.height); | |
| } | |
| }); | |
| } | |
| } // <-- End of else statement wrapping the detection logic | |
| const pct = Math.min(100, Math.round((time / duration) * 100)); | |
| progressBar.style.width = `${pct}%`; | |
| progressPercent.innerText = `${pct}%`; | |
| statsText.innerText = `Extracted ${extractedFrames.length} specific instances.`; | |
| const now = performance.now(); | |
| const fps = Math.round(1000 / (now - lastTime)); | |
| document.getElementById('fps-counter').innerText = `${fps} SEEK/S`; | |
| lastTime = now; | |
| } | |
| cleanup(); | |
| }; | |
| }; | |
| function isFaceInBody(faceBox, bodyBox) { | |
| if (!bodyBox || !faceBox) return false; | |
| const fCenterX = faceBox.x + faceBox.width / 2; | |
| const fCenterY = faceBox.y + faceBox.height / 2; | |
| return fCenterX >= bodyBox.originX && fCenterX <= bodyBox.originX + bodyBox.width && | |
| fCenterY >= bodyBox.originY && fCenterY <= bodyBox.originY + bodyBox.height; | |
| } | |
| function addFrameToUI(src, time, type, index) { | |
| const wrapper = document.createElement('div'); | |
| const sizeClasses = type === 'Face' ? "w-32 h-32" : (type === 'Body' ? "h-40 w-auto min-w-[100px]" : "w-64 h-auto aspect-video"); | |
| const badgeColor = type === 'Face' ? 'bg-emerald-600' : (type === 'Body' ? 'bg-indigo-600' : 'bg-slate-600'); | |
| wrapper.className = `frame-wrapper group relative bg-slate-900 rounded-xl overflow-hidden border border-white/5 hover:border-indigo-500/50 transition-all shadow-xl flex-shrink-0 cursor-pointer ${sizeClasses}`; | |
| wrapper.id = `frame-wrapper-${index}`; | |
| wrapper.onclick = () => window.toggleFrameSelection(index); | |
| wrapper.innerHTML = ` | |
| <img src="${src}" class="w-full h-full object-contain bg-black/50 pointer-events-none select-none"> | |
| <!-- Overlay Checkmark for Selections --> | |
| <div class="selection-overlay absolute inset-0 bg-indigo-500/20 opacity-0 transition-opacity flex items-center justify-center pointer-events-none"> | |
| <div class="bg-indigo-500 rounded-full w-8 h-8 flex items-center justify-center text-white shadow-lg"> | |
| <i class="fas fa-check"></i> | |
| </div> | |
| </div> | |
| <!-- Action Buttons --> | |
| <div class="absolute top-2 left-2 flex gap-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <!-- Zoom/Preview Button --> | |
| <button class="bg-black/70 hover:bg-indigo-600 text-white p-2 rounded-lg text-xs transition-colors shadow" onclick="event.stopPropagation(); window.showPreview('${src}')" title="Preview Frame"> | |
| <i class="fas fa-search-plus"></i> | |
| </button> | |
| <!-- Clipboard Button --> | |
| <button class="bg-black/70 hover:bg-amber-500 text-white p-2 rounded-lg text-xs transition-colors shadow" onclick="event.stopPropagation(); window.addToClipboard('${src}', 'videoflow_frame_${time.toFixed(2)}s_${index}.jpg')" title="Save to Clipboard"> | |
| <i class="fas fa-clipboard-check"></i> | |
| </button> | |
| <!-- Download Button --> | |
| <button class="bg-black/70 hover:bg-emerald-600 text-white p-2 rounded-lg text-xs transition-colors shadow" onclick="event.stopPropagation(); window.downloadSingleFrame('${src}', ${time}, '${type}', ${index})" title="Download Frame"> | |
| <i class="fas fa-download"></i> | |
| </button> | |
| </div> | |
| <!-- Status Tags --> | |
| <div class="absolute bottom-2 left-2 bg-black/60 px-2 py-0.5 rounded text-[9px] font-mono text-indigo-300 pointer-events-none"> | |
| T+ ${time.toFixed(1)}s | |
| </div> | |
| <div class="absolute top-2 right-2 ${badgeColor} px-1.5 py-0.5 rounded text-[8px] font-bold text-white uppercase shadow pointer-events-none"> | |
| ${type} | |
| </div> | |
| `; | |
| resultsEl.appendChild(wrapper); | |
| const container = document.getElementById('results-container'); | |
| if (container) container.scrollTop = container.scrollHeight; | |
| } | |
| // --- GLOBAL UI HELPERS FOR VIDEOFLOW SELECTIONS & PREVIEWS --- | |
| window.toggleFrameSelection = function(index) { | |
| if (window.selectedFrames.has(index)) { | |
| window.selectedFrames.delete(index); | |
| } else { | |
| window.selectedFrames.add(index); | |
| } | |
| const frameDiv = document.getElementById(`frame-wrapper-${index}`); | |
| const overlay = frameDiv.querySelector('.selection-overlay'); | |
| if (window.selectedFrames.has(index)) { | |
| overlay.classList.remove('opacity-0'); | |
| overlay.classList.add('opacity-100'); | |
| frameDiv.classList.add('ring-2', 'ring-indigo-500'); | |
| } else { | |
| overlay.classList.add('opacity-0'); | |
| overlay.classList.remove('opacity-100'); | |
| frameDiv.classList.remove('ring-2', 'ring-indigo-500'); | |
| } | |
| updateActionButtons(); | |
| }; | |
| window.showPreview = function(src) { | |
| document.getElementById('preview-modal-img').src = src; | |
| document.getElementById('video-preview-modal').classList.remove('hidden'); | |
| }; | |
| window.downloadSingleFrame = function(src, time, type, index) { | |
| const a = document.createElement('a'); | |
| a.href = src; | |
| a.download = `frame_${time.toFixed(2)}s_${type}_${index}.jpg`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| }; | |
| document.getElementById('close-preview').onclick = () => document.getElementById('video-preview-modal').classList.add('hidden'); | |
| document.getElementById('video-preview-modal').onclick = (e) => { | |
| if(e.target === document.getElementById('video-preview-modal')) { | |
| document.getElementById('video-preview-modal').classList.add('hidden'); | |
| } | |
| }; | |
| function updateActionButtons() { | |
| const count = window.selectedFrames.size; | |
| const total = extractedFrames.length; | |
| if (count > 0) { | |
| downloadBtn.innerHTML = `<i class="fas fa-file-export"></i> Download ${count} Selected`; | |
| exportImmagerBtn.innerHTML = `<i class="fas fa-magic"></i> Export ${count} to Immager`; | |
| selectAllBtn.innerHTML = "Deselect All"; | |
| } else { | |
| downloadBtn.innerHTML = `<i class="fas fa-file-export"></i> Download All`; | |
| exportImmagerBtn.innerHTML = `<i class="fas fa-magic"></i> Export All to Immager`; | |
| selectAllBtn.innerHTML = "Select All"; | |
| } | |
| if (total > 0) { | |
| downloadBtn.classList.remove('hidden'); | |
| exportImmagerBtn.classList.remove('hidden'); | |
| selectAllBtn.classList.remove('hidden'); | |
| } else { | |
| downloadBtn.classList.add('hidden'); | |
| exportImmagerBtn.classList.add('hidden'); | |
| selectAllBtn.classList.add('hidden'); | |
| } | |
| } | |
| selectAllBtn.onclick = () => { | |
| if (window.selectedFrames.size > 0) { | |
| window.selectedFrames.clear(); | |
| } else { | |
| extractedFrames.forEach((_, i) => window.selectedFrames.add(i)); | |
| } | |
| extractedFrames.forEach((_, i) => { | |
| const frameDiv = document.getElementById(`frame-wrapper-${i}`); | |
| if(!frameDiv) return; | |
| const overlay = frameDiv.querySelector('.selection-overlay'); | |
| if (window.selectedFrames.has(i)) { | |
| overlay.classList.remove('opacity-0'); | |
| overlay.classList.add('opacity-100'); | |
| frameDiv.classList.add('ring-2', 'ring-indigo-500'); | |
| } else { | |
| overlay.classList.add('opacity-0'); | |
| overlay.classList.remove('opacity-100'); | |
| frameDiv.classList.remove('ring-2', 'ring-indigo-500'); | |
| } | |
| }); | |
| updateActionButtons(); | |
| }; | |
| function dataURLtoFile(dataurl, filename) { | |
| let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], | |
| bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n); | |
| while(n--){ | |
| u8arr[n] = bstr.charCodeAt(n); | |
| } | |
| return new File([u8arr], filename, {type:mime}); | |
| } | |
| exportImmagerBtn.onclick = () => { | |
| const framesToExport = window.selectedFrames.size > 0 | |
| ? Array.from(window.selectedFrames).map(i => extractedFrames[i]) | |
| : extractedFrames; | |
| if (framesToExport.length === 0) return; | |
| const originalText = exportImmagerBtn.innerHTML; | |
| exportImmagerBtn.innerHTML = '<i class="fas fa-spinner animate-spin"></i> Transferring...'; | |
| exportImmagerBtn.disabled = true; | |
| setTimeout(() => { | |
| const files = framesToExport.map((f, i) => { | |
| const filename = `videoflow_${f.time.toFixed(2)}s_${f.type}_${i}.jpg`; | |
| return dataURLtoFile(f.data, filename); | |
| }); | |
| // Dispatch event to React Immager | |
| window.dispatchEvent(new CustomEvent('SEND_TO_IMMAGER', { detail: files })); | |
| // Navigate to Immager View automatically | |
| switchApp('immager'); | |
| exportImmagerBtn.innerHTML = originalText; | |
| exportImmagerBtn.disabled = false; | |
| }, 100); | |
| }; | |
| function cleanup() { | |
| isProcessing = false; | |
| startBtn.innerHTML = '<i class="fas fa-microchip"></i> Start AI Extraction'; | |
| startBtn.classList.replace('bg-red-600', 'bg-indigo-600'); | |
| if (scannerLine) scannerLine.style.display = "none"; | |
| statusLabel.innerText = "Process Complete"; | |
| window.selectedFrames.clear(); // Reset Selection | |
| if(extractedFrames.length > 0) { | |
| updateActionButtons(); | |
| } else { | |
| if (emptyState) emptyState.classList.remove('hidden'); | |
| statsText.innerText = "Scan complete. No matching frames found."; | |
| updateActionButtons(); | |
| } | |
| } | |
| downloadBtn.onclick = async () => { | |
| const framesToExport = window.selectedFrames.size > 0 | |
| ? Array.from(window.selectedFrames).map(i => extractedFrames[i]) | |
| : extractedFrames; | |
| if (framesToExport.length === 0) return; | |
| const originalText = downloadBtn.innerHTML; | |
| downloadBtn.innerHTML = '<i class="fas fa-spinner animate-spin"></i> Zipping...'; | |
| downloadBtn.disabled = true; | |
| const zip = new window.JSZip(); | |
| framesToExport.forEach((f, index) => { | |
| const base64Data = f.data.replace(/^data:image\/(png|jpg|jpeg);base64,/, ""); | |
| zip.file(`frame_${f.time.toFixed(2)}s_${f.type}_${index}.jpg`, base64Data, {base64: true}); | |
| }); | |
| try { | |
| const content = await zip.generateAsync({type: "blob"}); | |
| const url = URL.createObjectURL(content); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `HumanFrames_Extracted.zip`; | |
| a.click(); | |
| } catch (err) { | |
| console.error("Zipping failed", err); | |
| } finally { | |
| downloadBtn.innerHTML = originalText; | |
| downloadBtn.disabled = false; | |
| } | |
| }; | |
| </script> | |
| <!-- ========================================== --> | |
| <!-- LOGIC: APP 3 (URL FETCHER) --> | |
| <!-- ========================================== --> | |
| <script> | |
| (function() { | |
| const fetchBtn = document.getElementById('url-fetch-btn'); | |
| const textArea = document.getElementById('url-input-text'); | |
| const clearInputBtn = document.getElementById('url-clear-input-btn'); | |
| const fileInput = document.getElementById('url-file-input'); | |
| const dropZone = document.getElementById('url-drop-zone'); | |
| const fileNameDisplay = document.getElementById('url-file-name'); | |
| const resultsContainer = document.getElementById('url-results'); | |
| const emptyState = document.getElementById('url-empty-state'); | |
| const statsText = document.getElementById('url-stats-text'); | |
| const progressContainer = document.getElementById('url-progress-container'); | |
| const progressBar = document.getElementById('url-progress-bar'); | |
| const progressPercent = document.getElementById('url-progress-percent'); | |
| const selectAllBtn = document.getElementById('url-select-all-btn'); | |
| const importInput = document.getElementById('url-local-import'); | |
| const importBtn = document.getElementById('url-import-btn'); | |
| const downloadBtn = document.getElementById('url-download-btn'); | |
| const exportImmagerBtn = document.getElementById('url-export-immager-btn'); | |
| const clearBtn = document.getElementById('url-clear-btn'); | |
| const rembgBtn = document.getElementById('url-rembg-btn'); | |
| const previewModal = document.getElementById('url-preview-modal'); | |
| const previewImg = document.getElementById('url-preview-modal-img'); | |
| const closePreviewBtn = document.getElementById('url-close-preview'); | |
| const downloadPreviewBtn = document.getElementById('url-download-preview'); | |
| const previewViewport = document.getElementById('url-preview-viewport'); | |
| const previewWrapper = document.getElementById('url-preview-wrapper'); | |
| const fuskerInput = document.getElementById('fusker-input'); | |
| const fuskerBtn = document.getElementById('fusker-btn'); | |
| const fuskerSaveTxt = document.getElementById('fusker-save-txt'); | |
| const fuskerOpenTabs = document.getElementById('fusker-open-tabs'); | |
| const expandInputBtn = document.getElementById('url-expand-input-btn'); | |
| const editorModal = document.getElementById('url-editor-modal'); | |
| const editorTextarea = document.getElementById('url-editor-textarea'); | |
| const editorSaveBtn = document.getElementById('url-editor-save-btn'); | |
| const editorCloseBtn = document.getElementById('url-editor-close-btn'); | |
| const editorClearBtn = document.getElementById('url-editor-clear-btn'); | |
| let fetchedImages = []; // { id, url, blobUrl, isSelected, status } | |
| let isFetching = false; | |
| let abortFetch = false; | |
| let currentPreviewIndex = -1; | |
| let isRembgModelLoaded = false; // Tracks if AI models are cached | |
| let isRembgProcessing = false; | |
| let abortRembg = false; | |
| // Zoom & Pan State | |
| let urlZoom = 1; | |
| let urlPanX = 0; | |
| let urlPanY = 0; | |
| let isUrlDragging = false; | |
| let urlDragMoved = false; | |
| let urlDragStartX = 0; | |
| let urlDragStartY = 0; | |
| function updateUrlTransform(transition = 'none') { | |
| previewWrapper.style.transition = transition; | |
| previewWrapper.style.transform = `translate(${urlPanX}px, ${urlPanY}px) scale(${urlZoom})`; | |
| } | |
| // Scroll to Zoom (Now works anywhere in the black modal area, with pointer-based centering) | |
| previewModal.addEventListener('wheel', (e) => { | |
| if (previewModal.classList.contains('hidden') || previewModal.classList.contains('opacity-0')) return; | |
| e.preventDefault(); | |
| // Reversed zoom direction | |
| const zoomAmount = e.deltaY * 0.002; | |
| const oldZoom = urlZoom; | |
| let newZoom = oldZoom + zoomAmount; | |
| // When zooming out past 1.0, smoothly snap image back to center origin | |
| if (newZoom <= 1.0) { | |
| urlZoom = Math.max(0.5, newZoom); | |
| urlPanX = 0; | |
| urlPanY = 0; | |
| updateUrlTransform('transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)'); // Smooth snap to center | |
| } else { | |
| newZoom = Math.min(newZoom, 15); | |
| // Zoom to pointer logic | |
| const centerX = window.innerWidth / 2; | |
| const centerY = window.innerHeight / 2; | |
| const mouseX = e.clientX - centerX; | |
| const mouseY = e.clientY - centerY; | |
| const scaleChange = newZoom / oldZoom; | |
| urlPanX = mouseX - (mouseX - urlPanX) * scaleChange; | |
| urlPanY = mouseY - (mouseY - urlPanY) * scaleChange; | |
| urlZoom = newZoom; | |
| // Butter-smooth css transition specifically designed for scrolling | |
| updateUrlTransform('transform 0.1s ease-out'); | |
| } | |
| }, { passive: false }); | |
| // Drag to Pan (Now works anywhere in the black modal area) | |
| previewModal.addEventListener('mousedown', (e) => { | |
| // Ignore drags if clicking on the close button or keyboard hints | |
| if (e.target.closest('button') || e.target.closest('kbd')) return; | |
| isUrlDragging = true; | |
| urlDragMoved = false; | |
| previewWrapper.classList.replace('cursor-grab', 'cursor-grabbing'); | |
| updateUrlTransform('none'); // Remove transition for 1:1 instant dragging | |
| urlDragStartX = e.clientX - urlPanX; | |
| urlDragStartY = e.clientY - urlPanY; | |
| }); | |
| window.addEventListener('mousemove', (e) => { | |
| if (!isUrlDragging || previewModal.classList.contains('hidden')) return; | |
| urlDragMoved = true; | |
| urlPanX = e.clientX - urlDragStartX; | |
| urlPanY = e.clientY - urlDragStartY; | |
| updateUrlTransform('none'); // Instant 1:1 dragging | |
| }); | |
| window.addEventListener('mouseup', () => { | |
| if (isUrlDragging) { | |
| isUrlDragging = false; | |
| previewWrapper.classList.replace('cursor-grabbing', 'cursor-grab'); | |
| } | |
| }); | |
| // Fullscreen Editor Logic | |
| expandInputBtn.onclick = () => { | |
| editorTextarea.value = textArea.value; | |
| editorModal.classList.remove('hidden'); | |
| void editorModal.offsetWidth; | |
| editorModal.classList.remove('opacity-0', 'pointer-events-none'); | |
| editorModal.classList.add('opacity-100', 'pointer-events-auto'); | |
| editorTextarea.focus(); | |
| }; | |
| function closeUrlEditor() { | |
| editorModal.classList.remove('opacity-100', 'pointer-events-auto'); | |
| editorModal.classList.add('opacity-0', 'pointer-events-none'); | |
| setTimeout(() => editorModal.classList.add('hidden'), 300); | |
| } | |
| editorCloseBtn.onclick = closeUrlEditor; | |
| editorSaveBtn.onclick = () => { | |
| textArea.value = editorTextarea.value; | |
| closeUrlEditor(); | |
| }; | |
| editorClearBtn.onclick = () => { | |
| editorTextarea.value = ''; | |
| editorTextarea.focus(); | |
| }; | |
| // Handle Fusker Logic | |
| fuskerBtn.onclick = () => { | |
| const template = fuskerInput.value.trim(); | |
| if (!template) return alert('Enter a fusker template (e.g., http://site.com/img[1-5].jpg).'); | |
| const regex = /\[([a-zA-Z0-9]+)-([a-zA-Z0-9]+)\]/; | |
| const match = regex.exec(template); | |
| if (!match) return alert('Invalid format. Use [1-10] or [01-20] or [a-z].'); | |
| const fullMatch = match[0]; | |
| const start = match[1]; | |
| const end = match[2]; | |
| let generatedUrls = []; | |
| const isNumeric = !isNaN(start) && !isNaN(end); | |
| if (isNumeric) { | |
| let startNum = parseInt(start, 10); | |
| let endNum = parseInt(end, 10); | |
| let padLength = start.length > 1 && start.startsWith('0') ? start.length : 0; | |
| if (startNum > endNum) { | |
| let temp = startNum; startNum = endNum; endNum = temp; | |
| } | |
| if (endNum - startNum > 1000) return alert('Max 1000 links generated at a time to prevent browser issues.'); | |
| for (let i = startNum; i <= endNum; i++) { | |
| let valStr = i.toString(); | |
| if (padLength > 0) valStr = valStr.padStart(padLength, '0'); | |
| generatedUrls.push(template.replace(fullMatch, valStr)); | |
| } | |
| } else { | |
| let startChar = start.charCodeAt(0); | |
| let endChar = end.charCodeAt(0); | |
| if (startChar > endChar) { | |
| let temp = startChar; startChar = endChar; endChar = temp; | |
| } | |
| if (endChar - startChar > 1000) return alert('Max 1000 links.'); | |
| for (let i = startChar; i <= endChar; i++) { | |
| generatedUrls.push(template.replace(fullMatch, String.fromCharCode(i))); | |
| } | |
| } | |
| if (generatedUrls.length === 0) return; | |
| // 1. Overwrite Paste Textarea (No longer appending) | |
| textArea.value = generatedUrls.join('\n'); | |
| // 2. Save as TXT | |
| if (fuskerSaveTxt.checked) { | |
| const blob = new Blob([generatedUrls.join('\n')], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'fusker_generated_links.txt'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| // 3. Open in New Tabs | |
| if (fuskerOpenTabs.checked) { | |
| if (generatedUrls.length > 20) { | |
| if (!confirm(`You are about to open ${generatedUrls.length} tabs. Proceed?`)) return; | |
| } | |
| generatedUrls.forEach(url => window.open(url, '_blank')); | |
| } | |
| }; | |
| // Handle File Upload | |
| dropZone.onclick = () => fileInput.click(); | |
| fileInput.onchange = (e) => { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| fileNameDisplay.innerText = file.name; | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| const content = event.target.result; | |
| const current = textArea.value.trim(); | |
| textArea.value = current ? current + '\n' + content : content; | |
| }; | |
| reader.readAsText(file); | |
| } | |
| }; | |
| // Clear Input Box | |
| clearInputBtn.onclick = () => { | |
| textArea.value = ''; | |
| fileNameDisplay.innerText = 'Drag & drop or click'; | |
| fileInput.value = ''; | |
| }; | |
| // Global UI selection logic | |
| window.toggleUrlSelection = function(index) { | |
| const item = fetchedImages[index]; | |
| if(!item || item.status !== 'success') return; | |
| item.isSelected = !item.isSelected; | |
| const card = document.getElementById(`url-card-${index}`); | |
| const selectBtn = card.querySelector('button.absolute.top-2.left-2'); | |
| if (item.isSelected) { | |
| card.classList.add('ring-2', 'ring-teal-500'); | |
| if(selectBtn) selectBtn.className = 'absolute top-2 left-2 w-6 h-6 rounded-full border-2 bg-emerald-500 border-emerald-500 text-white flex items-center justify-center transition-all z-20 shadow-md'; | |
| } else { | |
| card.classList.remove('ring-2', 'ring-teal-500'); | |
| if(selectBtn) selectBtn.className = 'absolute top-2 left-2 w-6 h-6 rounded-full border-2 bg-black/40 border-white/60 text-transparent hover:border-white flex items-center justify-center transition-all z-20 shadow-md'; | |
| } | |
| updateUrlActionButtons(); | |
| }; | |
| window.showUrlPreview = function(index) { | |
| const item = fetchedImages[index]; | |
| if (!item || item.status !== 'success') return; | |
| currentPreviewIndex = index; | |
| previewImg.src = item.blobUrl; | |
| // Reset Zoom and Pan with NO transition so it pops cleanly | |
| urlZoom = 1; | |
| urlPanX = 0; | |
| urlPanY = 0; | |
| updateUrlTransform('none'); | |
| // Reset animation classes | |
| previewImg.classList.remove('url-zoom-active', 'url-float-anim'); | |
| previewImg.classList.add('url-zoom-enter'); | |
| previewModal.classList.remove('hidden'); | |
| // Trigger reflow to restart animation, then apply active/float/opacity classes | |
| void previewModal.offsetWidth; | |
| previewModal.classList.remove('opacity-0', 'pointer-events-none'); | |
| previewModal.classList.add('opacity-100', 'pointer-events-auto'); | |
| previewImg.classList.remove('url-zoom-enter'); | |
| previewImg.classList.add('url-zoom-active', 'url-float-anim'); | |
| }; | |
| function closeUrlPreview() { | |
| // Fade out the modal smoothly first | |
| previewModal.classList.remove('opacity-100', 'pointer-events-auto'); | |
| previewModal.classList.add('opacity-0', 'pointer-events-none'); | |
| // Wait for the transition to finish BEFORE resetting image size and hiding display | |
| // This eliminates the stutter completely | |
| setTimeout(() => { | |
| if (previewModal.classList.contains('opacity-0')) { | |
| previewModal.classList.add('hidden'); | |
| // Reset silently while hidden | |
| previewImg.classList.remove('url-zoom-active', 'url-float-anim'); | |
| previewImg.classList.add('url-zoom-enter'); | |
| urlZoom = 1; | |
| urlPanX = 0; | |
| urlPanY = 0; | |
| updateUrlTransform('none'); | |
| } | |
| }, 300); | |
| } | |
| // Keyboard navigation for preview | |
| document.addEventListener('keydown', (e) => { | |
| if (previewModal.classList.contains('opacity-0') || currentPreviewIndex === -1) return; | |
| const successfulIndices = fetchedImages | |
| .map((img, idx) => img.status === 'success' ? idx : -1) | |
| .filter(idx => idx !== -1); | |
| if (successfulIndices.length <= 1 && e.key !== 'Escape') return; | |
| let currentPos = successfulIndices.indexOf(currentPreviewIndex); | |
| if (e.key === 'ArrowRight') { | |
| currentPos = (currentPos + 1) % successfulIndices.length; | |
| window.showUrlPreview(successfulIndices[currentPos]); | |
| } else if (e.key === 'ArrowLeft') { | |
| currentPos = (currentPos - 1 + successfulIndices.length) % successfulIndices.length; | |
| window.showUrlPreview(successfulIndices[currentPos]); | |
| } else if (e.key === 'Escape') { | |
| closeUrlPreview(); | |
| } | |
| }); | |
| window.downloadSingleUrlImage = function(blobUrl, originalUrl) { | |
| const a = document.createElement('a'); | |
| a.href = blobUrl; | |
| let filename = "image.jpg"; | |
| try { | |
| const parsed = new URL(originalUrl); | |
| const parts = parsed.pathname.split('/'); | |
| const lastPart = parts[parts.length - 1]; | |
| if(lastPart && lastPart.includes('.')) filename = lastPart; | |
| } catch(e) {} | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| }; | |
| closePreviewBtn.onclick = closeUrlPreview; | |
| downloadPreviewBtn.onclick = () => { | |
| if (currentPreviewIndex === -1) return; | |
| const item = fetchedImages[currentPreviewIndex]; | |
| if (item && item.status === 'success') { | |
| window.downloadSingleUrlImage(item.blobUrl, item.url); | |
| } | |
| }; | |
| // Close preview when clicking the background (but ignore clicks resulting from a drag) | |
| previewModal.onclick = (e) => { | |
| if (urlDragMoved) return; | |
| if (e.target === previewModal || e.target === previewViewport || e.target === previewWrapper) { | |
| closeUrlPreview(); | |
| } | |
| }; | |
| clearBtn.onclick = () => { | |
| fetchedImages = []; | |
| resultsContainer.innerHTML = ''; | |
| emptyState.classList.remove('hidden'); | |
| statsText.innerText = 'Gallery cleared.'; | |
| updateUrlActionButtons(); | |
| }; | |
| // Local Image Import Logic | |
| importBtn.onclick = () => importInput.click(); | |
| importInput.onchange = (e) => { | |
| if (e.target.files && e.target.files.length > 0) { | |
| const files = Array.from(e.target.files); | |
| emptyState.classList.add('hidden'); | |
| let addedCount = 0; | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| if (!file.type.startsWith('image/')) continue; | |
| const idx = fetchedImages.length; | |
| const blobUrl = URL.createObjectURL(file); | |
| // Create standard image object | |
| const imageObj = { | |
| id: idx, | |
| url: file.name, // Use filename as title | |
| blobUrl: blobUrl, | |
| blob: file, | |
| isSelected: false, | |
| status: 'success', | |
| isBgRemoved: false, | |
| sizeStr: formatBytes(file.size), | |
| formatStr: file.type.split('/')[1] ? file.type.split('/')[1].toUpperCase() : 'IMG', | |
| dimensions: 'Loading...' // Placeholder | |
| }; | |
| // Map common types for cleaner display | |
| if (imageObj.formatStr === 'JPEG') imageObj.formatStr = 'JPG'; | |
| if (imageObj.formatStr === 'SVG+XML') imageObj.formatStr = 'SVG'; | |
| // Async fetch dimensions | |
| const img = new Image(); | |
| img.src = blobUrl; | |
| img.onload = () => { | |
| imageObj.dimensions = `${img.naturalWidth} x ${img.naturalHeight}`; | |
| const card = document.getElementById(`url-card-${idx}`); | |
| if (card) { | |
| const badge = card.querySelector('.absolute.top-2.right-2'); | |
| if (badge) badge.innerText = imageObj.dimensions; | |
| } | |
| }; | |
| fetchedImages.push(imageObj); | |
| renderUrlImage(imageObj, idx); | |
| addedCount++; | |
| } | |
| statsText.innerText = `Imported ${addedCount} local images.`; | |
| updateUrlActionButtons(); | |
| e.target.value = null; // Reset to allow re-importing same files | |
| } | |
| }; | |
| function updateUrlActionButtons() { | |
| const successful = fetchedImages.filter(img => img.status === 'success'); | |
| const selected = successful.filter(img => img.isSelected); | |
| if (fetchedImages.length > 0) { | |
| clearBtn.classList.remove('hidden'); | |
| } else { | |
| clearBtn.classList.add('hidden'); | |
| } | |
| if (successful.length > 0) { | |
| selectAllBtn.classList.remove('hidden'); | |
| downloadBtn.classList.remove('hidden'); | |
| exportImmagerBtn.classList.remove('hidden'); | |
| if (selected.length > 0) { | |
| downloadBtn.innerHTML = `<i class="fas fa-file-archive"></i> ZIP ${selected.length} Selected`; | |
| exportImmagerBtn.innerHTML = `<i class="fas fa-magic"></i> Export ${selected.length} to Immager`; | |
| selectAllBtn.innerText = selected.length === successful.length ? "Deselect All" : "Select All"; | |
| const eligibleSelected = selected.filter(img => !img.isBgRemoved); | |
| if (eligibleSelected.length > 0) { | |
| if (!isRembgProcessing) { | |
| rembgBtn.classList.remove('hidden'); | |
| rembgBtn.className = 'px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-bold rounded-xl transition-all shadow-lg flex items-center gap-2'; | |
| rembgBtn.innerHTML = `<i class="fas fa-eraser"></i> Remove BG (${eligibleSelected.length})`; | |
| } | |
| } else { | |
| if (!isRembgProcessing) rembgBtn.classList.add('hidden'); | |
| } | |
| } else { | |
| downloadBtn.innerHTML = `<i class="fas fa-file-archive"></i> ZIP All (${successful.length})`; | |
| exportImmagerBtn.innerHTML = `<i class="fas fa-magic"></i> Export All to Immager`; | |
| selectAllBtn.innerText = "Select All"; | |
| const eligibleAll = successful.filter(img => !img.isBgRemoved); | |
| if (eligibleAll.length > 0) { | |
| if (!isRembgProcessing) { | |
| rembgBtn.classList.remove('hidden'); | |
| rembgBtn.className = 'px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-bold rounded-xl transition-all shadow-lg flex items-center gap-2'; | |
| rembgBtn.innerHTML = `<i class="fas fa-eraser"></i> Remove BG All`; | |
| } | |
| } else { | |
| if (!isRembgProcessing) rembgBtn.classList.add('hidden'); | |
| } | |
| } | |
| } else { | |
| selectAllBtn.classList.add('hidden'); | |
| downloadBtn.classList.add('hidden'); | |
| exportImmagerBtn.classList.add('hidden'); | |
| if (!isRembgProcessing) rembgBtn.classList.add('hidden'); | |
| } | |
| } | |
| selectAllBtn.onclick = () => { | |
| const successful = fetchedImages.filter(img => img.status === 'success'); | |
| const anyUnselected = successful.some(img => !img.isSelected); | |
| fetchedImages.forEach((img, idx) => { | |
| if (img.status === 'success') { | |
| img.isSelected = anyUnselected; | |
| const card = document.getElementById(`url-card-${idx}`); | |
| const selectBtn = card ? card.querySelector('button.absolute.top-2.left-2') : null; | |
| if(card && selectBtn) { | |
| if (img.isSelected) { | |
| card.classList.add('ring-2', 'ring-teal-500'); | |
| selectBtn.className = 'absolute top-2 left-2 w-6 h-6 rounded-full border-2 bg-emerald-500 border-emerald-500 text-white flex items-center justify-center transition-all z-20 shadow-md'; | |
| } else { | |
| card.classList.remove('ring-2', 'ring-teal-500'); | |
| selectBtn.className = 'absolute top-2 left-2 w-6 h-6 rounded-full border-2 bg-black/40 border-white/60 text-transparent hover:border-white flex items-center justify-center transition-all z-20 shadow-md'; | |
| } | |
| } | |
| } | |
| }); | |
| updateUrlActionButtons(); | |
| }; | |
| function formatBytes(bytes, decimals = 0) { | |
| if (!+bytes) return '0 B'; | |
| const k = 1024; | |
| const dm = decimals < 0 ? 0 : decimals; | |
| const sizes = ['B', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; | |
| } | |
| function renderUrlImage(item, index) { | |
| let wrapper = document.getElementById(`url-card-${index}`); | |
| const isNew = !wrapper; | |
| if (isNew) { | |
| wrapper = document.createElement('div'); | |
| wrapper.id = `url-card-${index}`; | |
| } | |
| wrapper.className = `group relative bg-slate-800 rounded-xl overflow-hidden border ${item.isSelected ? 'border-teal-500 ring-1 ring-teal-500' : 'border-slate-700 hover:border-teal-500/50'} transition-all shadow-xl w-[260px] flex-shrink-0 flex flex-col`; | |
| if (item.status === 'success') { | |
| let displayTitle = item.url; | |
| try { displayTitle = decodeURIComponent(item.url).split('/').pop().split('?')[0] || item.url; } catch(e) {} | |
| wrapper.innerHTML = ` | |
| <!-- Image Container --> | |
| <div class="relative w-full h-40 bg-slate-900/50 flex items-center justify-center overflow-hidden cursor-pointer" onclick="event.stopPropagation(); window.showUrlPreview(${index})"> | |
| <img src="${item.blobUrl}" class="max-w-full max-h-full object-contain select-none transition-transform duration-500 group-hover:scale-105" title="Click to view full screen"> | |
| <!-- Selection Button (Top Left) --> | |
| <button class="absolute top-2 left-2 w-6 h-6 rounded-full border-2 ${item.isSelected ? 'bg-emerald-500 border-emerald-500 text-white' : 'bg-black/40 border-white/60 text-transparent hover:border-white'} flex items-center justify-center transition-all z-20 shadow-md" onclick="event.stopPropagation(); window.toggleUrlSelection(${index})"> | |
| <i class="fas fa-check text-[10px]"></i> | |
| </button> | |
| <!-- Dimensions Badge (Top Right) --> | |
| <div class="absolute top-2 right-2 bg-slate-900/80 backdrop-blur text-slate-200 text-[10px] font-semibold px-2 py-1 rounded border border-white/10 shadow pointer-events-none"> | |
| ${item.dimensions} | |
| </div> | |
| </div> | |
| <!-- Details Section --> | |
| <div class="p-3 flex flex-col gap-2.5 bg-slate-800 border-t border-slate-700/50"> | |
| <!-- Title/URL --> | |
| <div class="text-sm font-bold text-slate-200 truncate w-full" title="${item.url}"> | |
| ${displayTitle} | |
| </div> | |
| <!-- Tags & Actions Row --> | |
| <div class="flex items-center justify-between mt-1"> | |
| <!-- Left: Format & Size --> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="bg-blue-500/20 text-blue-400 border border-blue-500/30 text-[10px] font-bold px-2 py-1 rounded tracking-wide"> | |
| ${item.formatStr} | |
| </span> | |
| <span class="border border-slate-600 text-slate-400 text-[10px] font-bold px-2 py-1 rounded"> | |
| ${item.sizeStr} | |
| </span> | |
| </div> | |
| <!-- Right: Actions --> | |
| <div class="flex items-center gap-2 text-slate-400"> | |
| ${!item.isBgRemoved ? ` | |
| <button class="hover:text-indigo-400 transition-colors" onclick="event.stopPropagation(); window.removeBgSingleUrlImage(${index})" title="Remove Background"> | |
| <i class="fas fa-eraser"></i> | |
| </button> | |
| ` : ''} | |
| <button class="hover:text-teal-400 transition-colors" onclick="event.stopPropagation(); window.open('${item.url}', '_blank')" title="Open Original URL"> | |
| <i class="fas fa-link"></i> | |
| </button> | |
| <button class="hover:text-blue-400 transition-colors" onclick="event.stopPropagation(); window.open('https://lens.google.com/uploadbyurl?url=' + encodeURIComponent('${item.url}'), '_blank')" title="Search with Google Lens"> | |
| <i class="fas fa-search"></i> | |
| </button> | |
| <button class="hover:text-red-400 transition-colors" onclick="event.stopPropagation(); window.open('https://yandex.com/images/search?rpt=imageview&url=' + encodeURIComponent('${item.url}'), '_blank')" title="Search with Yandex Images"> | |
| <i class="fab fa-yandex"></i> | |
| </button> | |
| <button class="hover:text-amber-400 transition-colors" onclick="event.stopPropagation(); window.addToClipboard('${item.blobUrl}', 'url_fetch_${index}.${item.formatStr ? item.formatStr.toLowerCase() : 'jpg'}')" title="Save to Clipboard"> | |
| <i class="fas fa-clipboard-check"></i> | |
| </button> | |
| <button class="hover:text-emerald-400 transition-colors" onclick="event.stopPropagation(); window.downloadSingleUrlImage('${item.blobUrl}', '${item.url}', '${item.formatStr}')" title="Download"> | |
| <i class="fas fa-download"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } else { | |
| wrapper.className = `bg-slate-800 rounded-xl overflow-hidden border border-red-500/20 w-[260px] h-[240px] flex flex-col items-center justify-center text-red-400 p-4 shadow-xl`; | |
| wrapper.innerHTML = ` | |
| <i class="fas fa-exclamation-circle text-3xl mb-3"></i> | |
| <span class="text-xs text-center break-all text-slate-400 overflow-hidden line-clamp-3 w-full">${item.url}</span> | |
| <span class="text-sm font-bold mt-2 text-center text-red-500">Failed to load</span> | |
| `; | |
| } | |
| if (isNew) { | |
| resultsContainer.appendChild(wrapper); | |
| } | |
| } | |
| window.removeBgSingleUrlImage = async function(index) { | |
| const item = fetchedImages[index]; | |
| if (!item || item.status !== 'success' || !item.blob) return; | |
| const card = document.getElementById(`url-card-${index}`); | |
| // Clean up queued visual states before saving original HTML | |
| card.classList.remove('queued-pulse'); | |
| const badge = card.querySelector('.queued-badge'); | |
| if (badge) badge.remove(); | |
| const originalHtml = card.innerHTML; | |
| const originalClassName = card.className; | |
| card.className = `bg-slate-800 rounded-xl overflow-hidden border border-indigo-500/50 w-[260px] h-[240px] flex flex-col items-center justify-center text-indigo-400 p-4 shadow-[0_0_15px_rgba(99,102,241,0.2)]`; | |
| card.innerHTML = `<i class="fas fa-magic animate-pulse text-4xl mb-4"></i><span class="text-sm font-bold text-indigo-300">Removing Background...</span>${!isRembgModelLoaded ? '<span class="text-[10px] text-indigo-400/60 mt-2 text-center">First run loads AI models (~40MB).</span>' : ''}`; | |
| try { | |
| // Use unbundled esm.sh to properly resolve dependencies without breaking ONNX WASM paths | |
| const imglyModule = await import('https://esm.sh/@imgly/background-removal@1.4.3'); | |
| const rembgFunc = imglyModule.removeBackground || imglyModule.default; | |
| if (typeof rembgFunc !== 'function') { | |
| throw new Error("Background removal library failed to initialize."); | |
| } | |
| // Use unpkg for the model data to guarantee transparent CORS handling | |
| const config = { | |
| publicPath: "https://unpkg.com/@imgly/background-removal-data@1.4.3/dist/" | |
| }; | |
| // Directly pass the blob, as the library handles it natively now that WASM isn't broken | |
| const newBlob = await rembgFunc(item.blob, config); | |
| isRembgModelLoaded = true; // Mark as loaded so subsequent runs skip the ~40MB message | |
| item.blob = newBlob; | |
| item.blobUrl = URL.createObjectURL(newBlob); | |
| item.formatStr = 'PNG'; | |
| let width = 0, height = 0; | |
| try { | |
| const img = new Image(); | |
| img.src = item.blobUrl; | |
| await new Promise((resolve) => { | |
| img.onload = () => { width = img.naturalWidth; height = img.naturalHeight; resolve(); }; | |
| img.onerror = resolve; | |
| }); | |
| item.dimensions = width && height ? `${width} x ${height}` : 'Unknown'; | |
| } catch(e) {} | |
| item.sizeStr = formatBytes(newBlob.size); | |
| item.isBgRemoved = true; | |
| renderUrlImage(item, index); | |
| updateUrlActionButtons(); | |
| } catch(e) { | |
| console.error("Rembg Error:", e); | |
| alert(e.message || "Failed to remove background. See console for details."); | |
| card.className = originalClassName; | |
| card.innerHTML = originalHtml; | |
| } | |
| }; | |
| rembgBtn.onclick = async () => { | |
| if (isRembgProcessing) { | |
| abortRembg = true; | |
| rembgBtn.innerHTML = `<i class="fas fa-spinner animate-spin"></i> Stopping...`; | |
| rembgBtn.disabled = true; // Prevent double clicks while aborting | |
| return; | |
| } | |
| const successful = fetchedImages.filter(img => img.status === 'success'); | |
| let toProcess = successful.filter(img => img.isSelected && !img.isBgRemoved); | |
| if (successful.filter(img => img.isSelected).length === 0) { | |
| toProcess = successful.filter(img => !img.isBgRemoved); | |
| } | |
| if (toProcess.length === 0) return; | |
| isRembgProcessing = true; | |
| abortRembg = false; | |
| rembgBtn.disabled = false; // Ensure it stays clickable to act as a Stop button | |
| // Change to a Red Stop Button | |
| rembgBtn.className = 'px-4 py-2 bg-red-600 hover:bg-red-500 text-white text-sm font-bold rounded-xl transition-all shadow-lg flex items-center gap-2'; | |
| // Mark queued items with minimal pulse animation and badge | |
| toProcess.forEach(img => { | |
| const card = document.getElementById(`url-card-${img.id}`); | |
| if (card) { | |
| card.classList.add('queued-pulse'); | |
| const imgContainer = card.querySelector('.relative.w-full.h-40'); | |
| if (imgContainer && !card.querySelector('.queued-badge')) { | |
| const queuedBadge = document.createElement('div'); | |
| queuedBadge.className = 'absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] flex items-center justify-center z-30 queued-badge transition-all'; | |
| queuedBadge.innerHTML = '<span class="bg-indigo-600/90 text-white text-[10px] uppercase tracking-wider font-bold px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 border border-white/10"><span class="w-1.5 h-1.5 rounded-full bg-indigo-200 animate-ping"></span> Wait</span>'; | |
| imgContainer.appendChild(queuedBadge); | |
| } | |
| } | |
| }); | |
| for (let i = 0; i < toProcess.length; i++) { | |
| if (abortRembg) break; | |
| rembgBtn.innerHTML = `<i class="fas fa-stop-circle"></i> Stop Processing (${i+1}/${toProcess.length})`; | |
| await window.removeBgSingleUrlImage(toProcess[i].id); | |
| } | |
| // Cleanup queue visualization for any aborted/remaining images | |
| toProcess.forEach(img => { | |
| const card = document.getElementById(`url-card-${img.id}`); | |
| if (card) { | |
| card.classList.remove('queued-pulse'); | |
| const badge = card.querySelector('.queued-badge'); | |
| if (badge) badge.remove(); | |
| } | |
| }); | |
| isRembgProcessing = false; | |
| abortRembg = false; | |
| rembgBtn.disabled = false; | |
| updateUrlActionButtons(); | |
| }; | |
| fetchBtn.onclick = async () => { | |
| if (isFetching) { | |
| abortFetch = true; | |
| return; | |
| } | |
| const text = textArea.value; | |
| const regex = /(https?:\/\/[^\s]+)/g; | |
| let urls = text.match(regex) || []; | |
| urls = [...new Set(urls)]; // Remove duplicates | |
| if (urls.length === 0) { | |
| alert("No valid URLs found. Please enter valid http/https links."); | |
| return; | |
| } | |
| isFetching = true; | |
| abortFetch = false; | |
| // Morph button to "Stop" with new animated gradient classes | |
| fetchBtn.innerHTML = '<i class="fas fa-stop-circle text-xl drop-shadow-md"></i> <span class="drop-shadow-md tracking-wide">Stop Fetching</span>'; | |
| fetchBtn.className = 'w-full bg-gradient-to-r from-red-600 to-rose-500 hover:from-red-500 hover:to-rose-400 text-white font-bold py-3.5 rounded-xl shadow-[0_0_20px_rgba(225,29,72,0.4)] hover:shadow-[0_0_25px_rgba(225,29,72,0.6)] transform hover:-translate-y-0.5 transition-all duration-300 flex items-center justify-center gap-3 text-base mt-6 border border-red-400/30'; | |
| fetchedImages = []; | |
| resultsContainer.innerHTML = ''; | |
| emptyState.classList.add('hidden'); | |
| progressContainer.classList.remove('hidden'); | |
| let successCount = 0; | |
| let failCount = 0; | |
| for (let i = 0; i < urls.length; i++) { | |
| if (abortFetch) break; | |
| const currentUrl = urls[i]; | |
| progressBar.style.width = `${((i) / urls.length) * 100}%`; | |
| progressPercent.innerText = `${i}/${urls.length}`; | |
| // Added 'blob' directly to the object to prevent re-fetching later | |
| const imageObj = { id: i, url: currentUrl, blobUrl: null, blob: null, isSelected: false, status: 'pending', isBgRemoved: false }; | |
| try { | |
| // Fetch the image as a blob to allow direct ZIP download later | |
| const response = await fetch(currentUrl, { mode: 'cors' }); | |
| if (!response.ok) throw new Error("Network response was not ok"); | |
| const blob = await response.blob(); | |
| const blobUrl = URL.createObjectURL(blob); | |
| if (!blob.type.startsWith('image/')) throw new Error("Not an image"); | |
| imageObj.blobUrl = blobUrl; | |
| imageObj.blob = blob; // Save directly to memory | |
| imageObj.status = 'success'; | |
| // Calculate dimensions, format, and size for gallery | |
| let width = 0, height = 0; | |
| try { | |
| const img = new Image(); | |
| img.src = blobUrl; | |
| await new Promise((resolve) => { | |
| img.onload = () => { width = img.naturalWidth; height = img.naturalHeight; resolve(); }; | |
| img.onerror = resolve; | |
| }); | |
| } catch(e) {} | |
| imageObj.sizeStr = formatBytes(blob.size); | |
| let format = blob.type.replace('image/', '').toUpperCase(); | |
| if (format === 'JPEG') format = 'JPG'; | |
| else if (format === 'SVG+XML') format = 'SVG'; | |
| else if (format.length > 4) format = format.substring(0, 4); | |
| imageObj.formatStr = format; | |
| imageObj.dimensions = width && height ? `${width} x ${height}` : 'Unknown'; | |
| successCount++; | |
| } catch (e) { | |
| imageObj.status = 'error'; | |
| failCount++; | |
| } | |
| fetchedImages.push(imageObj); | |
| renderUrlImage(imageObj, i); | |
| if (abortFetch) break; // Check again in case it was cancelled during await | |
| } | |
| if (!abortFetch) { | |
| progressBar.style.width = '100%'; | |
| progressPercent.innerText = `${urls.length}/${urls.length}`; | |
| } | |
| setTimeout(() => progressContainer.classList.add('hidden'), 1000); | |
| statsText.innerText = abortFetch ? `Fetch stopped. Loaded ${successCount} images (${failCount} failed).` : `Fetched ${successCount} images (${failCount} failed).`; | |
| // Reset Button to original gradient classes | |
| isFetching = false; | |
| fetchBtn.innerHTML = '<i class="fas fa-cloud-download-alt text-xl drop-shadow-md"></i> <span class="drop-shadow-md tracking-wide">Fetch Images</span>'; | |
| fetchBtn.className = 'w-full bg-gradient-to-r from-teal-500 to-emerald-500 hover:from-teal-400 hover:to-emerald-400 text-white font-bold py-3.5 rounded-xl shadow-[0_0_20px_rgba(20,184,166,0.3)] hover:shadow-[0_0_25px_rgba(20,184,166,0.5)] transform hover:-translate-y-0.5 transition-all duration-300 flex items-center justify-center gap-3 text-base mt-6 border border-teal-400/30'; | |
| updateUrlActionButtons(); | |
| }; | |
| exportImmagerBtn.onclick = async () => { | |
| const successful = fetchedImages.filter(img => img.status === 'success'); | |
| let toExport = successful.filter(img => img.isSelected); | |
| if (toExport.length === 0) toExport = successful; | |
| if (toExport.length === 0) return; | |
| const originalText = exportImmagerBtn.innerHTML; | |
| exportImmagerBtn.innerHTML = '<i class="fas fa-spinner animate-spin"></i> Preparing exports...'; | |
| exportImmagerBtn.disabled = true; | |
| try { | |
| const files = []; | |
| for (let i = 0; i < toExport.length; i++) { | |
| const img = toExport[i]; | |
| const originalBlob = img.blob; | |
| if (!originalBlob) continue; | |
| // Update UI to show conversion progress for large batches | |
| if (i % 3 === 0) exportImmagerBtn.innerHTML = `<i class="fas fa-spinner animate-spin"></i> Processing ${i+1}/${toExport.length}...`; | |
| // AI models (like FaceAPI in Immager) sometimes fail to read formats like WEBP, GIF, or AVIF correctly. | |
| // We standardize ALL fetched images to pristine JPEGs via Canvas BEFORE sending them to Immager. | |
| const jpegBlob = await new Promise((resolve) => { | |
| const imageEl = new Image(); | |
| imageEl.crossOrigin = "anonymous"; // ADDED: Requests CORS to prevent canvas tainting | |
| imageEl.onload = () => { | |
| try { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = imageEl.width; | |
| canvas.height = imageEl.height; | |
| const ctx = canvas.getContext('2d'); | |
| // Fill white background (fixes issues with transparent PNGs/WEBPs turning black) | |
| ctx.fillStyle = '#FFFFFF'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage(imageEl, 0, 0); | |
| canvas.toBlob((b) => resolve(b || originalBlob), 'image/jpeg', 0.95); | |
| } catch (err) { | |
| // CATCH: Gracefully bypass Error Code 5 (SecurityError) in sandboxed previews | |
| console.warn("Canvas tainting blocked JPEG conversion. Bypassing sandbox constraint.", err); | |
| resolve(originalBlob); | |
| } | |
| }; | |
| imageEl.onerror = () => resolve(originalBlob); // Fallback to raw blob if canvas draw fails | |
| imageEl.src = URL.createObjectURL(originalBlob); | |
| }); | |
| const filename = `urlfetch_${Date.now()}_${i+1}.jpg`; | |
| const file = new File([jpegBlob], filename, { type: 'image/jpeg' }); | |
| files.push(file); | |
| } | |
| exportImmagerBtn.innerHTML = '<i class="fas fa-spinner animate-spin"></i> Transferring...'; | |
| // Dispatch event to React Immager globally | |
| window.dispatchEvent(new CustomEvent('SEND_TO_IMMAGER', { detail: files })); | |
| // Navigate to Immager View automatically | |
| switchApp('immager'); | |
| } catch (err) { | |
| console.error("Export failed", err); | |
| alert("Failed to export to Immager."); | |
| } finally { | |
| exportImmagerBtn.innerHTML = originalText; | |
| exportImmagerBtn.disabled = false; | |
| } | |
| }; | |
| downloadBtn.onclick = async () => { | |
| const successful = fetchedImages.filter(img => img.status === 'success'); | |
| let toZip = successful.filter(img => img.isSelected); | |
| if (toZip.length === 0) toZip = successful; | |
| if (toZip.length === 0) return; | |
| const originalText = downloadBtn.innerHTML; | |
| downloadBtn.innerHTML = '<i class="fas fa-spinner animate-spin"></i> Zipping...'; | |
| downloadBtn.disabled = true; | |
| try { | |
| const zip = new window.JSZip(); | |
| for (let i = 0; i < toZip.length; i++) { | |
| const img = toZip[i]; | |
| // Use the blob directly from memory | |
| const blob = img.blob; | |
| if (!blob) continue; | |
| let ext = "jpg"; | |
| if(blob.type === "image/png") ext = "png"; | |
| else if(blob.type === "image/gif") ext = "gif"; | |
| else if(blob.type === "image/webp") ext = "webp"; | |
| zip.file(`image_${i+1}.${ext}`, blob); | |
| } | |
| const content = await zip.generateAsync({type: "blob"}); | |
| const zipUrl = URL.createObjectURL(content); | |
| const a = document.createElement('a'); | |
| a.href = zipUrl; | |
| a.download = `Fetched_Images_${toZip.length}.zip`; | |
| a.click(); | |
| } catch (err) { | |
| console.error("Zipping failed", err); | |
| alert("Failed to create ZIP."); | |
| } finally { | |
| downloadBtn.innerHTML = originalText; | |
| downloadBtn.disabled = false; | |
| } | |
| }; | |
| window.downloadSingleUrlImage = function(blobUrl, originalUrl, forceFormat = null) { | |
| const a = document.createElement('a'); | |
| a.href = blobUrl; | |
| let filename = "image.jpg"; | |
| try { | |
| const parsed = new URL(originalUrl); | |
| const parts = parsed.pathname.split('/'); | |
| const lastPart = parts[parts.length - 1]; | |
| if(lastPart && lastPart.includes('.')) filename = lastPart; | |
| } catch(e) {} | |
| // Override the extension if a specific format (like PNG from RemBG) is provided | |
| if (forceFormat) { | |
| const nameWithoutExt = filename.includes('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename; | |
| filename = `${nameWithoutExt}.${forceFormat.toLowerCase()}`; | |
| } | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| }; | |
| closePreviewBtn.onclick = closeUrlPreview; | |
| downloadPreviewBtn.onclick = () => { | |
| if (currentPreviewIndex === -1) return; | |
| const item = fetchedImages[currentPreviewIndex]; | |
| if (item && item.status === 'success') { | |
| window.downloadSingleUrlImage(item.blobUrl, item.url, item.formatStr); | |
| } | |
| }; | |
| // Close preview when clicking the background (but ignore clicks resulting from a drag) | |
| previewModal.onclick = (e) => { | |
| if (urlDragMoved) return; | |
| if (e.target === previewModal || e.target === previewViewport || e.target === previewWrapper) { | |
| closeUrlPreview(); | |
| } | |
| }; | |
| })(); | |
| </script> | |
| </body> | |
| </html> |