Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Pro Video Converter | FFmpeg WASM</title> | |
| <script src="https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js"></script> | |
| <script src="https://unpkg.com/@ffmpeg/util@0.12.1/dist/umd/index.js"></script> | |
| <style> | |
| :root { | |
| --bg-dark: #0f172a; | |
| --bg-panel: #1e293b; | |
| --primary: #3b82f6; | |
| --primary-hover: #2563eb; | |
| --text-main: #f1f5f9; | |
| --text-muted: #94a3b8; | |
| --border: #334155; | |
| --success: #10b981; | |
| --danger: #ef4444; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; | |
| } | |
| body { | |
| background-color: var(--bg-dark); | |
| color: var(--text-main); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 20px; | |
| } | |
| /* Header */ | |
| header { | |
| width: 100%; | |
| max-width: 1000px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 30px; | |
| padding-bottom: 20px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| h1 { | |
| font-size: 1.5rem; | |
| background: linear-gradient(90deg, #3b82f6, #8b5cf6); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .anycoder-link a { | |
| color: var(--text-muted); | |
| text-decoration: none; | |
| font-size: 0.9rem; | |
| transition: color 0.3s; | |
| } | |
| .anycoder-link a:hover { | |
| color: var(--primary); | |
| } | |
| /* Main Container */ | |
| .app-container { | |
| width: 100%; | |
| max-width: 1000px; | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 20px; | |
| } | |
| /* Card Styles */ | |
| .card { | |
| background: var(--bg-panel); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 25px; | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); | |
| } | |
| /* File Upload Zone */ | |
| .upload-zone { | |
| border: 2px dashed var(--border); | |
| border-radius: 12px; | |
| padding: 40px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| position: relative; | |
| } | |
| .upload-zone:hover, .upload-zone.drag-over { | |
| border-color: var(--primary); | |
| background: rgba(59, 130, 246, 0.1); | |
| } | |
| .file-info { | |
| margin-top: 15px; | |
| font-size: 0.9rem; | |
| color: var(--text-muted); | |
| display: none; | |
| } | |
| #fileInput { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| opacity: 0; | |
| cursor: pointer; | |
| } | |
| /* Controls Section */ | |
| .controls-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 20px; | |
| } | |
| .settings-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
| gap: 20px; | |
| } | |
| .control-group { | |
| margin-bottom: 15px; | |
| } | |
| .control-group label { | |
| display: block; | |
| margin-bottom: 8px; | |
| font-size: 0.9rem; | |
| color: var(--text-muted); | |
| } | |
| /* Sliders */ | |
| input[type=range] { | |
| width: 100%; | |
| background: transparent; | |
| -webkit-appearance: none; | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| height: 16px; | |
| width: 16px; | |
| border-radius: 50%; | |
| background: var(--primary); | |
| cursor: pointer; | |
| margin-top: -6px; | |
| } | |
| input[type=range]::-webkit-slider-runnable-track { | |
| width: 100%; | |
| height: 4px; | |
| cursor: pointer; | |
| background: var(--border); | |
| border-radius: 2px; | |
| } | |
| .slider-value { | |
| float: right; | |
| color: var(--primary); | |
| font-weight: bold; | |
| } | |
| /* Buttons */ | |
| .btn { | |
| background: var(--primary); | |
| color: white; | |
| border: none; | |
| padding: 12px 24px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: background 0.2s; | |
| width: 100%; | |
| font-size: 1rem; | |
| } | |
| .btn:hover { | |
| background: var(--primary-hover); | |
| } | |
| .btn:disabled { | |
| background: var(--border); | |
| cursor: not-allowed; | |
| } | |
| /* Progress & Logs */ | |
| .log-container { | |
| height: 200px; | |
| overflow-y: auto; | |
| background: #000; | |
| border-radius: 8px; | |
| padding: 15px; | |
| font-family: 'Consolas', 'Monaco', monospace; | |
| font-size: 0.85rem; | |
| color: #10b981; | |
| border: 1px solid var(--border); | |
| } | |
| .log-entry { | |
| margin-bottom: 4px; | |
| border-bottom: 1px solid #1e293b; | |
| padding-bottom: 2px; | |
| } | |
| .log-warn { color: #eab308; } | |
| .log-error { color: #ef4444; } | |
| .log-info { color: #94a3b8; } | |
| /* Progress Bar */ | |
| .progress-wrapper { | |
| margin-bottom: 15px; | |
| display: none; | |
| } | |
| .progress-bar-bg { | |
| width: 100%; | |
| height: 10px; | |
| background: var(--border); | |
| border-radius: 5px; | |
| overflow: hidden; | |
| } | |
| .progress-bar-fill { | |
| height: 100%; | |
| background: var(--success); | |
| width: 0%; | |
| transition: width 0.3s ease; | |
| } | |
| .status-text { | |
| text-align: center; | |
| margin-top: 5px; | |
| font-size: 0.8rem; | |
| color: var(--text-muted); | |
| } | |
| /* Video Result */ | |
| #outputVideo { | |
| width: 100%; | |
| margin-top: 15px; | |
| border-radius: 8px; | |
| display: none; | |
| } | |
| .hidden { display: none ; } | |
| /* Loading Spinner */ | |
| .spinner { | |
| border: 3px solid rgba(255,255,255,0.1); | |
| border-left-color: var(--primary); | |
| border-radius: 50%; | |
| width: 20px; | |
| height: 20px; | |
| animation: spin 1s linear infinite; | |
| display: inline-block; | |
| vertical-align: middle; | |
| margin-right: 10px; | |
| } | |
| @keyframes spin { 100% { transform: rotate(360deg); } } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>FFmpeg Video Processor</h1> | |
| <div class="anycoder-link"> | |
| Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank">anycoder</a> | |
| </div> | |
| </header> | |
| <div class="app-container"> | |
| <!-- Upload Section --> | |
| <div class="card"> | |
| <div class="upload-zone" id="dropZone"> | |
| <input type="file" id="fileInput" accept="video/*"> | |
| <div class="icon">📁</div> | |
| <h3>Click or Drag Video File Here</h3> | |
| <p style="color: var(--text-muted); font-size: 0.9rem; margin-top: 5px;">Supports MP4, MKV, MOV, AVI</p> | |
| <div class="file-info" id="fileInfo"></div> | |
| </div> | |
| </div> | |
| <!-- Settings Section --> | |
| <div class="card hidden" id="settingsCard"> | |
| <div class="controls-header"> | |
| <h2>Conversion Settings</h2> | |
| </div> | |
| <div class="settings-grid"> | |
| <!-- Resolution Slider --> | |
| <div class="control-group"> | |
| <label> | |
| Scale Resolution | |
| <span class="slider-value" id="scaleValue">Original</span> | |
| </label> | |
| <input type="range" id="scaleRange" min="0" max="100" step="10" value="0"> | |
| <p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 5px;"> | |
| 0 = Original Size. Lower values reduce file size significantly. | |
| </p> | |
| </div> | |
| <!-- Compression Quality --> | |
| <div class="control-group"> | |
| <label> | |
| Compression Level (CRF) | |
| <span class="slider-value" id="crfValue">23</span> | |
| </label> | |
| <input type="range" id="crfRange" min="18" max="35" step="1" value="23"> | |
| <p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 5px;"> | |
| Lower = Better Quality (Larger File). Higher = Lower Quality (Smaller File). | |
| </p> | |
| </div> | |
| <!-- Format Selection --> | |
| <div class="control-group"> | |
| <label>Output Format</label> | |
| <select id="formatSelect" style="width: 100%; padding: 8px; background: var(--bg-dark); color: white; border: 1px solid var(--border); border-radius: 6px;"> | |
| <option value="mp4">MP4 (H.265)</option> | |
| <option value="mp4">MP4 (H.264)</option> | |
| <option value="webm">WebM (VP9)</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div style="margin-top: 20px;"> | |
| <button class="btn" id="convertBtn">Start Conversion</button> | |
| </div> | |
| </div> | |
| <!-- Progress & Logs Section --> | |
| <div class="card hidden" id="progressCard"> | |
| <div class="progress-wrapper" id="progressWrapper"> | |
| <div class="progress-bar-bg"> | |
| <div class="progress-bar-fill" id="progressBar"></div> | |
| </div> | |
| <div class="status-text" id="progressText">Initializing...</div> | |
| </div> | |
| <div class="controls-header"> | |
| <h3>Conversion Logs</h3> | |
| <button id="clearLogBtn" style="background:transparent; border:none; color:var(--text-muted); cursor:pointer; font-size:0.8rem;">Clear</button> | |
| </div> | |
| <div class="log-container" id="logContainer"> | |
| <!-- Logs go here --> | |
| </div> | |
| <div id="resultArea" style="margin-top: 20px; display:none; text-align: center;"> | |
| <h3>Conversion Complete!</h3> | |
| <video id="outputVideo" controls></video> | |
| <br><br> | |
| <a id="downloadLink" class="btn" style="text-decoration: none; display: inline-block;" download>Download Converted File</a> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { FFmpeg } = FFmpegWASM; | |
| const { fetchFile } = FFmpegUtil; | |
| let ffmpeg = null; | |
| let selectedFile = null; | |
| // DOM Elements | |
| const fileInput = document.getElementById('fileInput'); | |
| const dropZone = document.getElementById('dropZone'); | |
| const fileInfo = document.getElementById('fileInfo'); | |
| const settingsCard = document.getElementById('settingsCard'); | |
| const progressCard = document.getElementById('progressCard'); | |
| const convertBtn = document.getElementById('convertBtn'); | |
| const logContainer = document.getElementById('logContainer'); | |
| const progressBar = document.getElementById('progressBar'); | |
| const progressText = document.getElementById('progressText'); | |
| const scaleRange = document.getElementById('scaleRange'); | |
| const scaleValue = document.getElementById('scaleValue'); | |
| const crfRange = document.getElementById('crfRange'); | |
| const crfValue = document.getElementById('crfValue'); | |
| const outputVideo = document.getElementById('outputVideo'); | |
| const downloadLink = document.getElementById('downloadLink'); | |
| const resultArea = document.getElementById('resultArea'); | |
| const progressWrapper = document.getElementById('progressWrapper'); | |
| // Initialize FFmpeg | |
| const initFFmpeg = async () => { | |
| try { | |
| ffmpeg = new FFmpeg(); | |
| ffmpeg.on('log', ({ message }) => { | |
| addLog(message); | |
| }); | |
| ffmpeg.on('progress', ({ progress, time }) => { | |
| updateProgress(progress, time); | |
| }); | |
| // Loading the core from a CDN | |
| // Note: Multithreading requires SharedArrayBuffer (COOP/COEP headers). | |
| // If hosted locally without headers, use single-threaded fallback logic or warn user. | |
| await ffmpeg.load({ | |
| coreURL: "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js", | |
| }); | |
| addLog("System Ready. Load a video file to begin."); | |
| } catch (e) { | |
| addLog("Error loading FFmpeg: " + e.message, "error"); | |
| console.error(e); | |
| } | |
| }; | |
| // Initialize immediately | |
| initFFmpeg(); | |
| // UI Interaction - File Upload | |
| fileInput.addEventListener('change', (e) => { | |
| if (e.target.files.length > 0) { | |
| handleFile(e.target.files[0]); | |
| } | |
| }); | |
| // Drag and Drop | |
| 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'); | |
| if (e.dataTransfer.files.length > 0) { | |
| handleFile(e.dataTransfer.files[0]); | |
| } | |
| }); | |
| function handleFile(file) { | |
| selectedFile = file; | |
| fileInfo.style.display = 'block'; | |
| fileInfo.innerHTML = `Selected: <strong>${file.name}</strong> (${formatBytes(file.size)})`; | |
| settingsCard.classList.remove('hidden'); | |
| // Reset UI | |
| resultArea.style.display = 'none'; | |
| progressCard.classList.add('hidden'); | |
| logContainer.innerHTML = ''; | |
| addLog(`File Loaded: ${file.name}`); | |
| } | |
| // UI - Sliders | |
| scaleRange.addEventListener('input', (e) => { | |
| scaleValue.innerText = e.target.value == 0 ? "Original" : e.target.value + "%"; | |
| }); | |
| crfRange.addEventListener('input', (e) => { | |
| crfValue.innerText = e.target.value; | |
| }); | |
| document.getElementById('clearLogBtn').addEventListener('click', () => { | |
| logContainer.innerHTML = ''; | |
| }); | |
| // Conversion Logic | |
| convertBtn.addEventListener('click', async () => { | |
| if (!ffmpeg || !ffmpeg.loaded) { | |
| alert("FFmpeg is still loading or failed to load. Please refresh."); | |
| return; | |
| } | |
| if (!selectedFile) return; | |
| // Lock UI | |
| convertBtn.disabled = true; | |
| convertBtn.innerHTML = '<span class="spinner"></span> Processing...'; | |
| progressCard.classList.remove('hidden'); | |
| resultArea.style.display = 'none'; | |
| progressWrapper.style.display = 'block'; | |
| progressBar.style.width = '0%'; | |
| progressText.innerText = "Reading file..."; | |
| logContainer.innerHTML = ''; // Clear previous logs | |
| try { | |
| const inputName = 'input' + getExtension(selectedFile.name); | |
| const outputName = 'output.mp4'; // We default to mp4 for simplicity in browser | |
| addLog("Writing file to virtual filesystem..."); | |
| // Write file to MEMFS | |
| await ffmpeg.writeFile(inputName, await fetchFile(selectedFile)); | |
| // Build Command | |
| const args = ['-i', inputName]; | |
| // Video Filters (Scale) | |
| const scaleSetting = scaleRange.value; | |
| if (scaleSetting > 0) { | |
| // Scale to percentage of original | |
| args.push('-vf', `scale=-2:ih*${scaleSetting/100}`); | |
| } | |
| // Codec & Quality | |
| // Using libx265 for better compression (smaller files) or libx264 for compatibility | |
| // If user selects webm, use libvpx-vp9 | |
| const format = document.getElementById('formatSelect').value; | |
| const crf = crfRange.value; | |
| if (format === 'webm') { | |
| args.push('-c:v', 'libvpx-vp9', '-crf', crf, '-b:v', '0'); | |
| // args.push('-c:v', 'libvpx-vp9', '-crf', crf); // VP9 logic | |
| } else { | |
| args.push('-c:v', 'libx265', '-crf', crf, '-preset', 'fast'); | |
| } | |
| // Audio fallback if needed | |
| args.push('-c:a', 'aac', '-b:a', '128k'); | |
| args.push(outputName); | |
| addLog("Starting conversion...", "info"); | |
| addLog(`Command: ffmpeg ${args.join(' ')}`, "info"); | |
| // Execute | |
| await ffmpeg.exec(args); | |
| // Read Output | |
| addLog("Processing complete. Preparing download..."); | |
| const data = await ffmpeg.readFile(outputName); | |
| // Create Blob URL | |
| const blob = new Blob([data.buffer], { type: format === 'webm' ? 'video/webm' : 'video/mp4' }); | |
| const url = URL.createObjectURL(blob); | |
| // Display Result | |
| outputVideo.src = url; | |
| outputVideo.style.display = 'block'; | |
| downloadLink.href = url; | |
| downloadLink.download = `converted_${selectedFile.name.split('.')[0]}.${format}`; | |
| resultArea.style.display = 'block'; | |
| progressBar.style.width = '100%'; | |
| progressText.innerText = "Done!"; | |
| addLog("Success! File ready for download.", "success"); | |
| } catch (error) { | |
| addLog(`Error: ${error.message}`, "error"); | |
| progressText.innerText = "Error occurred"; | |
| convertBtn.disabled = false; | |
| convertBtn.innerHTML = "Start Conversion"; | |
| return; | |
| } | |
| // Cleanup | |
| convertBtn.disabled = false; | |
| convertBtn.innerHTML = "Start Conversion"; | |
| // Clean up virtual file system to free memory | |
| try { | |
| const inputName = 'input' + getExtension(selectedFile.name); | |
| await ffmpeg.deleteFile(inputName); | |
| await ffmpeg.deleteFile('output.mp4'); | |
| } catch(e) {} | |
| }); | |
| // Utilities | |
| function addLog(msg, type = 'log') { | |
| const div = document.createElement('div'); | |
| div.className = `log-entry log-${type}`; | |
| div.innerText = `[${new Date().toLocaleTimeString()}] ${msg}`; | |
| logContainer.appendChild(div); | |
| logContainer.scrollTop = logContainer.scrollHeight; | |
| } | |
| function updateProgress(progress, time) { | |
| const percentage = Math.round(progress * 100); | |
| progressBar.style.width = `${percentage}%`; | |
| // Format time roughly | |
| const timeStr = time ? ` @ ${Math.round(time)}s` : ''; | |
| progressText.innerText = `Processing... ${percentage}%${timeStr}`; | |
| } | |
| function formatBytes(bytes, decimals = 2) { | |
| if (!+bytes) return '0 Bytes'; | |
| const k = 1024; | |
| const dm = decimals < 0 ? 0 : decimals; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; | |
| } | |
| function getExtension(filename) { | |
| return filename.slice((filename.lastIndexOf(".") - 1 >>> 0) + 2); | |
| } | |
| </script> | |
| </body> | |
| </html> |