Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>CineFlow Studio - Free Image to Video Generator</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@ffmpeg/util@0.12.1/dist/umd/index.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background: #0a0a0a; | |
| } | |
| .glass-panel { | |
| background: rgba(255, 255, 255, 0.03); | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .gradient-text { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .drop-zone { | |
| background: repeating-linear-gradient( | |
| 45deg, | |
| rgba(99, 102, 241, 0.05), | |
| rgba(99, 102, 241, 0.05) 10px, | |
| rgba(99, 102, 241, 0.1) 10px, | |
| rgba(99, 102, 241, 0.1) 20px | |
| ); | |
| border: 2px dashed rgba(99, 102, 241, 0.3); | |
| transition: all 0.3s ease; | |
| } | |
| .drop-zone.drag-over { | |
| background: repeating-linear-gradient( | |
| 45deg, | |
| rgba(99, 102, 241, 0.15), | |
| rgba(99, 102, 241, 0.15) 10px, | |
| rgba(99, 102, 241, 0.25) 10px, | |
| rgba(99, 102, 241, 0.25) 20px | |
| ); | |
| border-color: #6366f1; | |
| transform: scale(1.02); | |
| } | |
| .timeline-item { | |
| transition: all 0.2s ease; | |
| } | |
| .timeline-item:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.5); | |
| } | |
| .progress-bar { | |
| background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7); | |
| background-size: 200% 100%; | |
| animation: gradient-shift 2s ease infinite; | |
| } | |
| @keyframes gradient-shift { | |
| 0% { background-position: 0% 50%; } | |
| 50% { background-position: 100% 50%; } | |
| 100% { background-position: 0% 50%; } | |
| } | |
| .preview-container { | |
| aspect-ratio: 16/9; | |
| background: linear-gradient(135deg, #1a1a1a, #2d2d2d); | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| background: transparent; | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-webkit-slider-track { | |
| background: rgba(255,255,255,0.1); | |
| height: 6px; | |
| border-radius: 3px; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| background: #6366f1; | |
| height: 16px; | |
| width: 16px; | |
| border-radius: 50%; | |
| margin-top: -5px; | |
| transition: all 0.2s; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| box-shadow: 0 0 20px rgba(99, 102, 241, 0.5); | |
| } | |
| .frame-preview { | |
| animation: fadeIn 0.3s ease; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: scale(0.9); } | |
| to { opacity: 1; transform: scale(1); } | |
| } | |
| </style> | |
| </head> | |
| <body class="text-white min-h-screen overflow-x-hidden"> | |
| <!-- Header --> | |
| <header class="glass-panel sticky top-0 z-50 border-b border-white/10"> | |
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center"> | |
| <i data-lucide="film" class="w-5 h-5 text-white"></i> | |
| </div> | |
| <div> | |
| <h1 class="text-xl font-bold tracking-tight">CineFlow <span class="gradient-text">Studio</span></h1> | |
| <p class="text-xs text-gray-400">Client-side Video Generator</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <span class="hidden sm:flex items-center gap-2 text-xs text-gray-400 bg-white/5 px-3 py-1.5 rounded-full"> | |
| <i data-lucide="cpu" class="w-3 h-3"></i> | |
| <span id="cpu-status">Ready</span> | |
| </span> | |
| <button onclick="resetAll()" class="text-sm text-gray-400 hover:text-white transition-colors"> | |
| Reset | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | |
| <div class="grid lg:grid-cols-3 gap-8"> | |
| <!-- Left Panel: Upload & Timeline --> | |
| <div class="lg:col-span-2 space-y-6"> | |
| <!-- Upload Zone --> | |
| <div class="glass-panel rounded-2xl p-6"> | |
| <div id="dropZone" class="drop-zone rounded-xl p-12 text-center cursor-pointer"> | |
| <input type="file" id="fileInput" multiple accept="image/*" class="hidden"> | |
| <div class="space-y-4"> | |
| <div class="w-16 h-16 mx-auto bg-indigo-500/20 rounded-2xl flex items-center justify-center"> | |
| <i data-lucide="upload-cloud" class="w-8 h-8 text-indigo-400"></i> | |
| </div> | |
| <div> | |
| <p class="text-lg font-medium text-white">Drop images here or click to browse</p> | |
| <p class="text-sm text-gray-400 mt-1">Supports JPG, PNG, WebP • Unlimited files • Private & Secure</p> | |
| </div> | |
| <button onclick="document.getElementById('fileInput').click()" class="inline-flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2.5 rounded-lg font-medium transition-all hover:scale-105"> | |
| <i data-lucide="plus" class="w-4 h-4"></i> | |
| Select Images | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Timeline --> | |
| <div class="glass-panel rounded-2xl p-6"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <h3 class="text-lg font-semibold flex items-center gap-2"> | |
| <i data-lucide="layers" class="w-5 h-5 text-indigo-400"></i> | |
| Timeline | |
| <span id="frameCount" class="text-xs bg-white/10 px-2 py-0.5 rounded-full text-gray-400">0 frames</span> | |
| </h3> | |
| <div class="flex gap-2"> | |
| <button onclick="clearAll()" class="text-xs text-red-400 hover:text-red-300 flex items-center gap-1"> | |
| <i data-lucide="trash-2" class="w-3 h-3"></i> | |
| Clear | |
| </button> | |
| </div> | |
| </div> | |
| <div id="timeline" class="space-y-2 max-h-96 overflow-y-auto pr-2"> | |
| <div class="text-center py-12 text-gray-500"> | |
| <i data-lucide="image-off" class="w-12 h-12 mx-auto mb-3 opacity-20"></i> | |
| <p>No images added yet</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right Panel: Settings & Preview --> | |
| <div class="space-y-6"> | |
| <!-- Preview Window --> | |
| <div class="glass-panel rounded-2xl p-6"> | |
| <h3 class="text-lg font-semibold mb-4 flex items-center gap-2"> | |
| <i data-lucide="play-circle" class="w-5 h-5 text-indigo-400"></i> | |
| Preview | |
| </h3> | |
| <div class="preview-container rounded-xl overflow-hidden relative bg-black"> | |
| <img id="previewImage" src="" alt="Preview" class="w-full h-full object-contain hidden"> | |
| <div id="previewPlaceholder" class="absolute inset-0 flex items-center justify-center text-gray-500"> | |
| <div class="text-center"> | |
| <i data-lucide="video" class="w-12 h-12 mx-auto mb-2 opacity-20"></i> | |
| <p class="text-sm">Video preview will appear here</p> | |
| </div> | |
| </div> | |
| <div id="previewOverlay" class="absolute inset-0 bg-black/50 hidden items-center justify-center"> | |
| <span class="text-2xl font-bold" id="previewCounter">1/10</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Settings --> | |
| <div class="glass-panel rounded-2xl p-6 space-y-6"> | |
| <h3 class="text-lg font-semibold flex items-center gap-2"> | |
| <i data-lucide="settings" class="w-5 h-5 text-indigo-400"></i> | |
| Export Settings | |
| </h3> | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="flex justify-between text-sm font-medium mb-2"> | |
| <span>Duration per image</span> | |
| <span id="durationValue" class="text-indigo-400">2s</span> | |
| </label> | |
| <input type="range" id="duration" min="0.5" max="10" step="0.5" value="2" class="w-full"> | |
| </div> | |
| <div> | |
| <label class="flex justify-between text-sm font-medium mb-2"> | |
| <span>Frame Rate (FPS)</span> | |
| <span id="fpsValue" class="text-indigo-400">30fps</span> | |
| </label> | |
| <input type="range" id="fps" min="15" max="60" step="15" value="30" class="w-full"> | |
| </div> | |
| <div> | |
| <label class="flex justify-between text-sm font-medium mb-2"> | |
| <span>Resolution</span> | |
| <span id="resolutionValue" class="text-indigo-400">1920×1080</span> | |
| </label> | |
| <select id="resolution" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500"> | |
| <option value="3840x2160">4K Ultra HD (3840×2160)</option> | |
| <option value="1920x1080" selected>Full HD (1920×1080)</option> | |
| <option value="1280x720">HD (1280×720)</option> | |
| <option value="854x480">480p (854×480)</option> | |
| <option value="640x360">360p (640×360)</option> | |
| </select> | |
| </div> | |
| <div class="flex items-center justify-between py-2"> | |
| <span class="text-sm font-medium">Transition Effect</span> | |
| <select id="transition" class="bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500"> | |
| <option value="none">Cut (None)</option> | |
| <option value="fade">Fade</option> | |
| <option value="slide">Slide</option> | |
| </select> | |
| </div> | |
| </div> | |
| <button id="generateBtn" onclick="generateVideo()" class="w-full bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white font-semibold py-4 rounded-xl transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"> | |
| <i data-lucide="render" class="w-5 h-5"></i> | |
| Generate Video | |
| </button> | |
| <div id="progressContainer" class="hidden space-y-2"> | |
| <div class="flex justify-between text-xs text-gray-400"> | |
| <span id="progressText">Initializing...</span> | |
| <span id="progressPercent">0%</span> | |
| </div> | |
| <div class="h-2 bg-white/10 rounded-full overflow-hidden"> | |
| <div id="progressBar" class="progress-bar h-full w-0 rounded-full transition-all duration-300"></div> | |
| </div> | |
| </div> | |
| <div id="downloadContainer" class="hidden"> | |
| <a id="downloadLink" href="#" download="cineflow-video.mp4" class="flex items-center justify-center gap-2 w-full bg-green-600 hover:bg-green-500 text-white font-semibold py-3 rounded-xl transition-all"> | |
| <i data-lucide="download" class="w-5 h-5"></i> | |
| Download Video | |
| </a> | |
| </div> | |
| </div> | |
| <!-- Info --> | |
| <div class="glass-panel rounded-2xl p-4 text-xs text-gray-400 space-y-2"> | |
| <div class="flex items-start gap-2"> | |
| <i data-lucide="shield-check" class="w-4 h-4 text-green-400 mt-0.5"></i> | |
| <span>All processing happens locally in your browser. No data is uploaded to any server.</span> | |
| </div> | |
| <div class="flex items-start gap-2"> | |
| <i data-lucide="infinity" class="w-4 h-4 text-indigo-400 mt-0.5"></i> | |
| <span>Unlimited generations. Limited only by your device's memory.</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| // Initialize Lucide icons | |
| lucide.createIcons(); | |
| // State | |
| let images = []; | |
| let ffmpeg = null; | |
| let isGenerating = false; | |
| // DOM Elements | |
| const dropZone = document.getElementById('dropZone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const timeline = document.getElementById('timeline'); | |
| const frameCount = document.getElementById('frameCount'); | |
| const generateBtn = document.getElementById('generateBtn'); | |
| const progressContainer = document.getElementById('progressContainer'); | |
| const progressBar = document.getElementById('progressBar'); | |
| const progressText = document.getElementById('progressText'); | |
| const progressPercent = document.getElementById('progressPercent'); | |
| const downloadContainer = document.getElementById('downloadContainer'); | |
| const downloadLink = document.getElementById('downloadLink'); | |
| // Event Listeners | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.add('drag-over'); | |
| }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| handleFiles(e.dataTransfer.files); | |
| }); | |
| fileInput.addEventListener('change', (e) => handleFiles(e.target.files)); | |
| document.getElementById('duration').addEventListener('input', (e) => { | |
| document.getElementById('durationValue').textContent = e.target.value + 's'; | |
| }); | |
| document.getElementById('fps').addEventListener('input', (e) => { | |
| document.getElementById('fpsValue').textContent = e.target.value + 'fps'; | |
| }); | |
| document.getElementById('resolution').addEventListener('change', (e) => { | |
| document.getElementById('resolutionValue').textContent = e.target.value.split('x').join('×'); | |
| }); | |
| function handleFiles(files) { | |
| const validFiles = Array.from(files).filter(file => file.type.startsWith('image/')); | |
| validFiles.forEach(file => { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| images.push({ | |
| id: Date.now() + Math.random(), | |
| src: e.target.result, | |
| name: file.name, | |
| type: file.type | |
| }); | |
| updateTimeline(); | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| } | |
| function updateTimeline() { | |
| frameCount.textContent = `${images.length} frame${images.length !== 1 ? 's' : ''}`; | |
| if (images.length === 0) { | |
| timeline.innerHTML = ` | |
| <div class="text-center py-12 text-gray-500"> | |
| <i data-lucide="image-off" class="w-12 h-12 mx-auto mb-3 opacity-20"></i> | |
| <p>No images added yet</p> | |
| </div> | |
| `; | |
| lucide.createIcons(); | |
| return; | |
| } | |
| timeline.innerHTML = images.map((img, index) => ` | |
| <div class="timeline-item flex items-center gap-3 bg-white/5 rounded-lg p-2 border border-white/5" id="frame-${img.id}"> | |
| <div class="relative w-20 h-12 bg-black rounded overflow-hidden flex-shrink-0"> | |
| <img src="${img.src}" class="w-full h-full object-cover frame-preview"> | |
| <div class="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity cursor-pointer" onclick="previewImage(${index})"> | |
| <i data-lucide="eye" class="w-4 h-4 text-white"></i> | |
| </div> | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <p class="text-sm font-medium truncate">${img.name}</p> | |
| <p class="text-xs text-gray-500">Frame ${index + 1}</p> | |
| </div> | |
| <div class="flex gap-1"> | |
| <button onclick="moveImage(${index}, -1)" class="p-1.5 hover:bg-white/10 rounded transition-colors ${index === 0 ? 'opacity-30 cursor-not-allowed' : ''}" ${index === 0 ? 'disabled' : ''}> | |
| <i data-lucide="chevron-up" class="w-4 h-4"></i> | |
| </button> | |
| <button onclick="moveImage(${index}, 1)" class="p-1.5 hover:bg-white/10 rounded transition-colors ${index === images.length - 1 ? 'opacity-30 cursor-not-allowed' : ''}" ${index === images.length - 1 ? 'disabled' : ''}> | |
| <i data-lucide="chevron-down" class="w-4 h-4"></i> | |
| </button> | |
| <button onclick="removeImage(${index})" class="p-1.5 hover:bg-red-500/20 text-red-400 rounded transition-colors"> | |
| <i data-lucide="x" class="w-4 h-4"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| lucide.createIcons(); | |
| } | |
| function removeImage(index) { | |
| images.splice(index, 1); | |
| updateTimeline(); | |
| } | |
| function moveImage(index, direction) { | |
| const newIndex = index + direction; | |
| if (newIndex < 0 || newIndex >= images.length) return; | |
| const temp = images[index]; | |
| images[index] = images[newIndex]; | |
| images[newIndex] = temp; | |
| updateTimeline(); | |
| } | |
| function previewImage(index) { | |
| const img = document.getElementById('previewImage'); | |
| const placeholder = document.getElementById('previewPlaceholder'); | |
| img.src = images[index].src; | |
| img.classList.remove('hidden'); | |
| placeholder.classList.add('hidden'); | |
| } | |
| function clearAll() { | |
| images = []; | |
| updateTimeline(); | |
| document.getElementById('previewImage').classList.add('hidden'); | |
| document.getElementById('previewPlaceholder').classList.remove('hidden'); | |
| } | |
| function resetAll() { | |
| clearAll(); | |
| document.getElementById('downloadContainer').classList.add('hidden'); | |
| progressContainer.classList.add('hidden'); | |
| } | |
| async function loadFFmpeg() { | |
| if (ffmpeg) return; | |
| const { FFmpeg } = FFmpegWASM; | |
| ffmpeg = new FFmpeg(); | |
| progressText.textContent = 'Loading FFmpeg...'; | |
| await ffmpeg.load(); | |
| ffmpeg.on('log', ({ message }) => { | |
| console.log('FFmpeg:', message); | |
| }); | |
| } | |
| async function generateVideo() { | |
| if (images.length === 0) { | |
| alert('Please add at least one image'); | |
| return; | |
| } | |
| if (isGenerating) return; | |
| try { | |
| isGenerating = true; | |
| generateBtn.disabled = true; | |
| generateBtn.innerHTML = '<i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i> Processing...'; | |
| lucide.createIcons(); | |
| progressContainer.classList.remove('hidden'); | |
| downloadContainer.classList.add('hidden'); | |
| // Load FFmpeg if not loaded | |
| if (!ffmpeg) { | |
| progressText.textContent = 'Loading video engine...'; | |
| await loadFFmpeg(); | |
| } | |
| const duration = parseFloat(document.getElementById('duration').value); | |
| const fps = parseInt(document.getElementById('fps').value); | |
| const resolution = document.getElementById('resolution').value.split('x'); | |
| const width = parseInt(resolution[0]); | |
| const height = parseInt(resolution[1]); | |
| progressText.textContent = 'Preparing images...'; | |
| // Process images | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = width; | |
| canvas.height = height; | |
| const ctx = canvas.getContext('2d'); | |
| let frameCount = 0; | |
| const totalFrames = Math.ceil(images.length * duration * fps); | |
| for (let i = 0; i < images.length; i++) { | |
| const img = new Image(); | |
| img.src = images[i].src; | |
| await new Promise(resolve => { | |
| img.onload = resolve; | |
| }); | |
| // Calculate frames for this image | |
| const framesForThisImage = Math.floor(duration * fps); | |
| for (let f = 0; f < framesForThisImage; f++) { | |
| ctx.fillStyle = '#000000'; | |
| ctx.fillRect(0, 0, width, height); | |
| // Scale image to fit while maintaining aspect ratio | |
| const scale = Math.min(width / img.width, height / img.height); | |
| const x = (width - img.width * scale) / 2; | |
| const y = (height - img.height * scale) / 2; | |
| ctx.drawImage(img, x, y, img.width * scale, img.height * scale); | |
| // Convert to blob and write to FFmpeg | |
| const blob = await new Promise(resolve => { | |
| canvas.toBlob(resolve, 'image/jpeg', 0.95); | |
| }); | |
| const buffer = await blob.arrayBuffer(); | |
| const frameName = `frame_${String(frameCount).padStart(6, '0')}.jpg`; | |
| await ffmpeg.writeFile(frameName, new Uint8Array(buffer)); | |
| frameCount++; | |
| // Update progress | |
| const percent = Math.round((frameCount / totalFrames) * 90); | |
| progressBar.style.width = percent + '%'; | |
| progressPercent.textContent = percent + '%'; | |
| progressText.textContent = `Processing frame ${frameCount}/${totalFrames}...`; | |
| } | |
| } | |
| progressText.textContent = 'Encoding video...'; | |
| progressBar.style.width = '95%'; | |
| // Generate video | |
| await ffmpeg.exec([ | |
| '-framerate', fps.toString(), | |
| '-pattern_type', 'glob', | |
| '-i', 'frame_*.jpg', | |
| '-c:v', 'libx264', | |
| '-pix_fmt', 'yuv420p', | |
| '-preset', 'ultrafast', | |
| '-crf', '23', | |
| 'output.mp4' | |
| ]); | |
| progressText.textContent = 'Finalizing...'; | |
| progressBar.style.width = '100%'; | |
| // Read output file | |
| const data = await ffmpeg.readFile('output.mp4'); | |
| const videoBlob = new Blob([data.buffer], { type: 'video/mp4' }); | |
| const url = URL.createObjectURL(videoBlob); | |
| // Setup download | |
| downloadLink.href = url; | |
| downloadContainer.classList.remove('hidden'); | |
| // Cleanup | |
| for (let i = 0; i < frameCount; i++) { | |
| try { | |
| await ffmpeg.deleteFile(`frame_${String(i).padStart(6, '0')}.jpg`); | |
| } catch (e) {} | |
| } | |
| progressText.textContent = 'Complete!'; | |
| } catch (error) { | |
| console.error('Error:', error); | |
| alert('Error generating video: ' + error.message); | |
| progressText.textContent = 'Error occurred'; | |
| } finally { | |
| isGenerating = false; | |
| generateBtn.disabled = false; | |
| generateBtn.innerHTML = '<i data-lucide="render" class="w-5 h-5"></i> Generate Video'; | |
| lucide.createIcons(); | |
| } | |
| } | |
| </script> | |
| <script src="https://deepsite.hf.co/deepsite-badge.js"></script> | |
| </body> | |
| </html> |