splug / pwa-7-image-load-error-correction-OG
trysem's picture
Create pwa-7-image-load-error-correction-OG
3b6e9fe verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Extraction Studio | PWA</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); }
/* --- 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="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 -->
<div class="lg:col-span-4 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&#10;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>
<!-- 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-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-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-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">&larr;</kbd> and <kbd class="px-2 py-1 bg-white/10 rounded mx-1 font-bold">&rarr;</kbd> to navigate<br>
<span class="mt-2 inline-block">Scroll to Zoom &bull; 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>
function switchApp(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);
}
}
</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');
// 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 = false;
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>);
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 [cropSettings, setCropSettings] = useState({ padding: 0.05, topPadding: 0.2, shape: 'square' });
const [showSettings, setShowSettings] = useState(false);
const [editingFace, setEditingFace] = useState(null);
const editorImgRef = useRef(null);
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 [pendingTransferFiles, setPendingTransferFiles] = useState(null);
// 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;
if (manualOffsets.resolution && manualOffsets.resolution !== 'auto') {
const res = parseInt(manualOffsets.resolution, 10);
targetW = res;
targetH = settings.shape === 'original' ? Math.round(res * (cropH / cropW)) : res;
}
canvas.width = targetW;
canvas.height = targetH;
ctx.drawImage(img, cropX, cropY, cropW, cropH, 0, 0, targetW, targetH);
return canvas.toDataURL('image/jpeg', 0.9);
};
const openEditor = async (face, groupIndex, faceIndex) => {
const img = new Image();
img.src = face.sourceUrl;
await new Promise(r => img.onload = r);
editorImgRef.current = img;
setEditingFace({
...face,
groupIndex,
faceIndex,
manualOffsets: face.manualOffsets || { x: 0, y: 0, zoom: 1, resolution: 'auto' },
previewUrl: face.cropDataUrl
});
setDragState({ isDragging: false, startX: 0, startY: 0, initialOffsetX: 0, initialOffsetY: 0 });
};
const updateManualCrop = (updates) => {
if (!editingFace || !editorImgRef.current) return;
const newOffsets = { ...editingFace.manualOffsets, ...updates };
// Ensure Unrecognized/NoFace images don't get forced into a tiny square with massive padding
const targetCropSettings = editingFace.isNoFace ? { padding: 0, topPadding: 0, shape: 'original' } : 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);
};
const updateGroupName = (groupId, newName) => {
setFaceGroups(prevGroups => prevGroups.map(g => g.id === groupId ? { ...g, name: newName } : g));
};
const handleDragStart = (e, faceId, sourceGroupId) => {
let dragItems = [];
// If the dragged face is selected, bundle all selected faces
if (selectedFaceIds.has(faceId)) {
faceGroups.forEach(g => {
g.faces.forEach(f => {
if (selectedFaceIds.has(f.id)) {
dragItems.push({ faceId: f.id, sourceGroupId: g.id });
}
});
});
} else {
// Otherwise, just drag this specific single face
dragItems = [{ faceId, sourceGroupId }];
}
// --- Custom Drag Image for Multiple Items ---
if (dragItems.length > 1) {
const dragGhost = document.createElement('div');
dragGhost.id = 'drag-ghost-container';
dragGhost.style.position = 'absolute';
dragGhost.style.top = '-1000px';
dragGhost.style.left = '-1000px';
dragGhost.style.width = '80px';
dragGhost.style.height = '80px';
// Get URLs for the first 3 faces to create a stack
const previewFaces = dragItems.slice(0, 3).map(item => {
let url = '';
faceGroups.forEach(g => {
const f = g.faces.find(face => face.id === item.faceId);
if (f) url = f.cropDataUrl;
});
return url;
});
// Reverse to render the bottom-most image first, top-most last
previewFaces.reverse().forEach((url, i) => {
if (url) {
const img = document.createElement('img');
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;
dragGhost.appendChild(img);
}
});
// Add the count badge
const badge = document.createElement('div');
badge.style.position = 'absolute';
badge.style.bottom = '0';
badge.style.right = '0';
badge.style.background = '#4f46e5';
badge.style.color = 'white';
badge.style.borderRadius = '999px';
badge.style.padding = '2px 8px';
badge.style.fontSize = '12px';
badge.style.fontWeight = 'bold';
badge.style.zIndex = '10';
badge.style.border = '2px solid white';
badge.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)';
badge.innerText = dragItems.length;
dragGhost.appendChild(badge);
document.body.appendChild(dragGhost);
e.dataTransfer.setDragImage(dragGhost, 40, 40);
setTimeout(() => {
if (dragGhost.parentNode) dragGhost.parentNode.removeChild(dragGhost);
}, 50);
}
draggedFaceRef.current = dragItems;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragItems.map(i => i.faceId).join(','));
setTimeout(() => { setIsDraggingFace(true); }, 0);
};
const handleDragEnd = (e) => {
setIsDraggingFace(false);
setDragOverGroupId(null);
draggedFaceRef.current = null;
};
const handleDragOver = (e, targetGroupId) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverGroupId !== targetGroupId) setDragOverGroupId(targetGroupId);
};
const handleDragLeave = (e) => { e.preventDefault(); };
const handleDrop = (e, targetGroupId) => {
e.preventDefault();
setIsDraggingFace(false);
setDragOverGroupId(null);
const dragItems = draggedFaceRef.current;
if (!dragItems || dragItems.length === 0) return;
// If dragging a single item to the exact same group, do nothing to prevent flicker
if (dragItems.length === 1 && dragItems[0].sourceGroupId === targetGroupId) return;
setTimeout(() => {
setFaceGroups(prevGroups => {
let movedFaces = [];
// 1. Remove all bundled faces from their respective source groups
let updatedGroups = prevGroups.map(group => {
const itemsToRemove = new Set(dragItems.filter(item => item.sourceGroupId === group.id).map(i => i.faceId));
if (itemsToRemove.size > 0) {
const extracted = group.faces.filter(f => itemsToRemove.has(f.id));
movedFaces.push(...extracted);
return { ...group, faces: group.faces.filter(f => !itemsToRemove.has(f.id)) };
}
return group;
});
if (movedFaces.length === 0) return prevGroups;
// 2. Add all bundled faces into the target group
if (targetGroupId === 'new-group') {
updatedGroups.unshift({
id: `group-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: '',
baseDescriptor: movedFaces[0].descriptor,
faces: movedFaces
});
} else {
updatedGroups = updatedGroups.map(group => {
if (group.id === targetGroupId) return { ...group, faces: [...group.faces, ...movedFaces] };
return group;
});
}
// 3. Clean up any empty groups
return updatedGroups.filter(g => g.faces.length > 0);
});
// Clear selection after a successful multi-drag
setSelectedFaceIds(new Set());
}, 0);
};
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;
}
// Do not apply global padding/square shape to Unrecognized images unless manually cropped
const targetCropSettings = face.isNoFace ? { padding: 0, topPadding: 0, shape: 'original' } : newSettings;
const newCropDataUrl = generateCrop(img, face.originalBox, targetCropSettings, face.manualOffsets || { x: 0, y: 0, zoom: 1, resolution: 'auto' });
updatedFaces.push({ ...face, cropDataUrl: newCropDataUrl });
}
setExtractedFaces(updatedFaces);
setFaceGroups(prevGroups => prevGroups.map(group => ({
...group,
faces: group.faces.map(gFace => {
const updatedFace = updatedFaces.find(uf => uf.id === gFace.id);
return updatedFace ? { ...gFace, cropDataUrl: updatedFace.cropDataUrl } : gFace;
})
})));
} catch (error) {
console.error("Error applying crop settings:", error);
} finally {
setIsProcessing(false);
setStatusText("Done");
}
};
const clusterFaces = useCallback((facesToCluster, threshold) => {
const groups = [];
const noFaceGroup = {
id: 'group-noface-special',
name: 'Unrecognized',
baseDescriptor: null,
faces: [],
isNoFaceGroup: true
};
facesToCluster.forEach(face => {
if (face.isNoFace) {
noFaceGroup.faces.push(face);
return;
}
let foundGroup = false;
for (let group of groups) {
if (group.isNoFaceGroup) 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);
// Append the Unrecognized group at the very end
if (noFaceGroup.faces.length > 0) {
groups.push(noFaceGroup);
}
setFaceGroups(groups);
}, []);
const processImages = async (files) => {
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];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.type.startsWith('image/')) continue;
setStatusText(`Scanning image ${i + 1} of ${files.length}...`);
setProgress(((i) / files.length) * 100);
try {
const img = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const image = new Image();
image.src = e.target.result;
image.onload = () => resolve(image);
image.onerror = reject;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
const detections = await window.faceapi.detectAllFaces(img)
.withFaceLandmarks()
.withFaceDescriptors();
const sourceUrl = URL.createObjectURL(file);
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
cropDataUrl: sourceUrl, // Keep original image display
descriptor: new Float32Array(128).fill(0), // Dummy descriptor
isNoFace: true
});
} else {
detections.forEach((det, idx) => {
const cropDataUrl = generateCrop(img, det.detection.box, cropSettings);
allExtractedFaces.push({
id: `face-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sourceFile: file.name,
sourceUrl: sourceUrl,
originalBox: det.detection.box,
cropDataUrl,
descriptor: det.descriptor,
isNoFace: false
});
});
}
} catch (err) { console.error(`Error processing ${file.name}`, err); }
}
setStatusText("Organizing faces by person...");
setExtractedFaces(allExtractedFaces);
clusterFaces(allExtractedFaces, matchThreshold);
setIsProcessing(false);
setProgress(100);
setStatusText("Done");
};
const onDrop = useCallback((e) => {
e.preventDefault();
if (isModelLoading || isProcessing) return;
const files = Array.from(e.dataTransfer.files);
processImages(files);
}, [isModelLoading, isProcessing]);
const onFileChange = (e) => {
if (e.target.files && e.target.files.length > 0) processImages(Array.from(e.target.files));
};
const toggleFace = (id) => {
const next = new Set(selectedFaceIds);
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 clearAll = () => {
setFaceGroups([]); setExtractedFaces([]); setSelectedFaceIds(new Set()); setProgress(0); setCollapsedGroups(new Set());
};
const confirmDelete = () => {
// 1. Remove from visual groups
setFaceGroups(prevGroups => {
return prevGroups.map(group => ({
...group,
faces: group.faces.filter(f => !selectedFaceIds.has(f.id))
})).filter(group => group.faces.length > 0); // Automatically remove empty groups
});
// 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() : `Person_${gIndex + 1}`;
let filePrefix = hasName ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : 'face';
if (group.isNoFaceGroup) {
folderName = "Unrecognized_Faces";
filePrefix = "unrecognized";
}
const selectedInGroup = group.faces.filter(f => selectedFaceIds.has(f.id));
if (selectedInGroup.length > 0) {
const folder = zip.folder(folderName);
selectedInGroup.forEach((face, fIndex) => {
const base64Data = face.cropDataUrl.split(',')[1];
const fileName = (hasName && !group.isNoFaceGroup) ? `${filePrefix}${fIndex + 1}.jpg` : `${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_Faces_${totalExported}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error("ZIP Generation Failed", e);
alert("Failed to generate ZIP file.");
} finally {
setIsProcessing(false);
setStatusText("Ready");
}
};
const downloadSingleFace = (e, face, group, fIndex) => {
e.stopPropagation();
const hasName = group.name && group.name.trim() !== '';
let filePrefix = hasName ? group.name.replace(/[^a-z0-9]/gi, '').toLowerCase() : 'face';
if (group.isNoFaceGroup) filePrefix = "unrecognized";
const fileName = (hasName && !group.isNoFaceGroup) ? `${filePrefix}${fIndex + 1}.jpg` : `${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">
<button onClick={() => setShowSettings(true)} className="text-sm flex items-center gap-2 text-neutral-400 hover:text-white transition-colors">
<SettingsIcon size={16} /> Crop Settings
</button>
{faceGroups.length > 0 && (
<button onClick={clearAll} className="text-sm flex items-center gap-2 text-neutral-400 hover:text-white transition-colors">
<Trash2Icon size={16} /> Start Over
</button>
)}
</div>
</header>
<main className="max-w-7xl mx-auto p-6 pb-32">
{faceGroups.length === 0 && (
<div className="mt-12">
<div
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
onClick={() => !isModelLoading && !isProcessing && fileInputRef.current.click()}
className={`
relative w-full max-w-2xl mx-auto flex flex-col items-center justify-center p-16
border-2 border-dashed rounded-3xl transition-all duration-200
${isModelLoading || isProcessing
? 'border-neutral-800 bg-neutral-900/30 cursor-not-allowed'
: 'border-neutral-700 bg-neutral-900/50 hover:bg-neutral-800 hover:border-indigo-500 cursor-pointer'}
`}
>
<input type="file" multiple accept="image/*" ref={fileInputRef} onChange={onFileChange} className="hidden" />
{(isModelLoading || isProcessing) ? (
<div className="flex flex-col items-center text-center">
<Loader2Icon size={48} className="text-indigo-500 animate-spin mb-6" />
<h3 className="text-xl font-medium text-white mb-2">{statusText}</h3>
{isProcessing && progress > 0 && (
<div className="w-full max-w-xs bg-neutral-800 rounded-full h-2 mt-4 overflow-hidden">
<div className="bg-indigo-500 h-2 rounded-full transition-all duration-300 ease-out" style={{ width: `${progress}%` }} />
</div>
)}
</div>
) : (
<div className="flex flex-col items-center text-center">
<div className="bg-neutral-800 p-4 rounded-2xl mb-6 shadow-inner">
<UploadCloudIcon size={40} className="text-indigo-400" />
</div>
<h3 className="text-2xl font-medium text-white mb-3">Upload Images</h3>
<p className="text-neutral-400 max-w-sm mb-6">Drag and drop your photos here, or click to browse. We'll find and group the faces.</p>
<button className="bg-white text-black px-6 py-2.5 rounded-full font-medium hover:bg-neutral-200 transition-colors">Select Images</button>
</div>
)}
</div>
{!isModelLoading && !isProcessing && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto mt-16 text-center">
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
<UsersIcon size={24} className="mx-auto text-indigo-400 mb-4" />
<h4 className="font-medium text-white mb-2">Smart Clustering</h4>
<p className="text-sm text-neutral-400">Groups faces of the same person together automatically.</p>
</div>
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
<ImageIcon size={24} className="mx-auto text-emerald-400 mb-4" />
<h4 className="font-medium text-white mb-2">Auto-Cropping</h4>
<p className="text-sm text-neutral-400">Extracts perfectly framed headshots ready for use.</p>
</div>
<div className="bg-neutral-900/30 p-6 rounded-2xl border border-neutral-800/50">
<CheckCircleIcon size={24} className="mx-auto text-amber-400 mb-4" />
<h4 className="font-medium text-white mb-2">100% Private</h4>
<p className="text-sm text-neutral-400">Everything runs entirely inside your browser. No server uploads.</p>
</div>
</div>
)}
</div>
)}
{faceGroups.length > 0 && (
<div className="animate-in">
{isDraggingFace && (
<div
className={`fixed top-24 left-1/2 -translate-x-1/2 z-50 w-full max-w-sm border-2 border-dashed rounded-2xl flex flex-col items-center justify-center p-6 shadow-2xl backdrop-blur-md transition-all ${dragOverGroupId === 'new-group' ? 'border-indigo-400 bg-indigo-500/30 scale-105' : 'border-neutral-500 bg-neutral-900/80 scale-100'}`}
onDragOver={(e) => handleDragOver(e, 'new-group')}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, 'new-group')}
>
<UsersIcon size={32} className="text-white mb-2" />
<p className="text-white font-medium text-center">Drop here to create a new person</p>
</div>
)}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h2 className="text-2xl font-semibold tracking-tight">Found {faceGroups.length} People</h2>
<div className="text-sm text-neutral-400 mt-1">Total unique faces: {faceGroups.reduce((acc, curr) => acc + curr.faces.length, 0)}</div>
</div>
<div className="flex flex-col items-end gap-1 bg-neutral-900/80 p-3 rounded-xl border border-neutral-800 shadow-inner">
<label className="text-sm text-neutral-300 font-medium flex justify-between w-full">
<span>Grouping Tolerance</span>
<span className="text-indigo-400 font-bold">{matchThreshold.toFixed(2)}</span>
</label>
<input type="range" min="0.30" max="0.70" step="0.01" value={matchThreshold} onChange={(e) => { const val = parseFloat(e.target.value); setMatchThreshold(val); clusterFaces(extractedFaces, val); }} className="w-48 md:w-64 accent-indigo-500 cursor-pointer" title="Lower = stricter matching. Higher = looser matching." />
<span className="text-xs text-neutral-500">Slide right to merge similar people</span>
</div>
</div>
<div className="columns-1 md:columns-2 lg:columns-3 xl:columns-4 gap-6 space-y-6">
{faceGroups.map((group, groupIndex) => {
const allSelected = group.faces.every(f => selectedFaceIds.has(f.id));
const someSelected = !allSelected && group.faces.some(f => selectedFaceIds.has(f.id));
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">
<img src={group.faces[0].cropDataUrl} className="w-full h-full object-cover pointer-events-none" alt={`Person ${groupIndex + 1}`} />
</div>
<div className="flex flex-col">
{group.isNoFaceGroup ? (
<div className="text-sm font-bold text-amber-500 w-32 truncate" title="No Face Detected">Unrecognized</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">
{group.isNoFaceGroup && (
<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(115px,1fr))] gap-3 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-2 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-2 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-2 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>
)}
</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> faces 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 Faces?</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 face{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">Smart Crop Settings</h3>
<button onClick={() => setShowSettings(false)} className="text-neutral-400 hover:text-white"><XIcon size={20}/></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-neutral-400 mb-2">Padding Style</label>
<select disabled={isProcessing} value={cropSettings.padding} onChange={(e) => applyGlobalCropSettings({...cropSettings, padding: parseFloat(e.target.value)})} className="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-2.5 text-white outline-none focus:border-indigo-500 disabled:opacity-50">
<option value="0.05">Very Tight (Exclude others)</option>
<option value="0.15">Tight</option>
<option value="0.3">Normal</option>
<option value="0.5">Wide</option>
</select>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Shape</label>
<select disabled={isProcessing} value={cropSettings.shape} onChange={(e) => applyGlobalCropSettings({...cropSettings, shape: e.target.value})} className="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-2.5 text-white outline-none focus:border-indigo-500 disabled:opacity-50">
<option value="square">Square</option>
<option value="original">Original Aspect</option>
</select>
</div>
<p className="text-xs text-neutral-500 mt-4 leading-relaxed">Changes apply instantly to all currently extracted faces.</p>
<button onClick={() => setShowSettings(false)} disabled={isProcessing} className="w-full mt-6 bg-indigo-600 hover:bg-indigo-500 text-white py-2.5 rounded-lg font-medium transition-colors disabled:opacity-50">Done</button>
</div>
</div>
</div>
)}
{editingFace && (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div className="bg-neutral-900 border border-neutral-800 rounded-2xl p-6 w-full max-w-lg flex flex-col items-center shadow-2xl animate-in">
<div className="w-full flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Manual Crop Adjust (Drag to Pan)</h3>
<button onClick={() => setEditingFace(null)} className="text-neutral-400 hover:text-white"><XIcon size={20}/></button>
</div>
<div className={`relative w-64 h-64 bg-neutral-800 rounded-xl overflow-hidden mb-6 border border-neutral-700 flex items-center justify-center shadow-inner ${dragState.isDragging ? 'cursor-grabbing' : 'cursor-grab'}`} onMouseDown={(e) => { setDragState({ isDragging: true, startX: e.clientX, startY: e.clientY, initialOffsetX: editingFace.manualOffsets.x, initialOffsetY: editingFace.manualOffsets.y }); }} onMouseMove={(e) => { if (!dragState.isDragging || !editingFace) return; const dx = e.clientX - dragState.startX; const dy = e.clientY - dragState.startY; 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: "GPU"
},
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 downloadBtn = document.getElementById('url-download-btn');
const exportImmagerBtn = document.getElementById('url-export-immager-btn');
const clearBtn = document.getElementById('url-clear-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;
// 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();
};
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";
} 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";
}
} else {
selectAllBtn.classList.add('hidden');
downloadBtn.classList.add('hidden');
exportImmagerBtn.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) {
const 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">
<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}.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}')" 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>
`;
}
resultsContainer.appendChild(wrapper);
}
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' };
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.onload = () => {
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);
};
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;
}
};
})();
</script>
<!-- ========================================== -->
<!-- DYNAMIC PWA REGISTRATION (SINGLE FILE) -->
<!-- ========================================== -->
<script>
(function() {
// 1. Generate & Inject Manifest dynamically
const manifestObj = {
"name": "AI Extraction Studio",
"short_name": "AI Studio",
"start_url": ".",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#4f46e5",
"description": "Image cropping and video extraction AI tools running locally in your browser.",
"icons": [{
"src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='120' fill='%234f46e5'/><path d='M150 350 L250 150 L350 350 Z' fill='white'/></svg>",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any maskable"
}]
};
const manifestBlob = new Blob([JSON.stringify(manifestObj)], {type: 'application/json'});
const manifestUrl = URL.createObjectURL(manifestBlob);
const link = document.createElement('link');
link.rel = 'manifest';
link.href = manifestUrl;
document.head.appendChild(link);
/* Note: Dynamic Service Worker registration via Blob URL has been removed.
Browsers block 'blob:' protocols for Service Workers for security reasons.
To make this a fully offline-installable PWA in the future, you will need to
host it on a local or live web server and link a separate 'sw.js' file.
*/
})();
</script>
</body>
</html>