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> | |
| <!-- FontAwesome for Icons --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <!-- Google Fonts --> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --primary: #6366f1; | |
| --primary-hover: #4f46e5; | |
| --bg-dark: #0f172a; | |
| --bg-card: #1e293b; | |
| --bg-input: #334155; | |
| --text-main: #f8fafc; | |
| --text-muted: #94a3b8; | |
| --border: #475569; | |
| --success: #10b981; | |
| --error: #ef4444; | |
| --warning: #f59e0b; | |
| --radius: 12px; | |
| --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| outline: none; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: var(--bg-dark); | |
| color: var(--text-main); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow-x: hidden; | |
| } | |
| /* Header */ | |
| header { | |
| background: rgba(15, 23, 42, 0.9); | |
| backdrop-filter: blur(10px); | |
| border-bottom: 1px solid var(--border); | |
| padding: 1rem 2rem; | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .logo { | |
| font-size: 1.25rem; | |
| font-weight: 700; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| color: var(--text-main); | |
| text-decoration: none; | |
| } | |
| .logo i { color: var(--primary); } | |
| /* Main Layout */ | |
| main { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 350px 1fr; | |
| gap: 2rem; | |
| padding: 2rem; | |
| max-width: 1600px; | |
| margin: 0 auto; | |
| width: 100%; | |
| } | |
| @media (max-width: 900px) { | |
| main { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| /* Panels */ | |
| .panel { | |
| background: var(--bg-card); | |
| border-radius: var(--radius); | |
| border: 1px solid var(--border); | |
| box-shadow: var(--shadow); | |
| padding: 1.5rem; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| height: fit-content; | |
| } | |
| h2 { | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| margin-bottom: 0.5rem; | |
| color: var(--text-main); | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| } | |
| /* File Upload Area */ | |
| .upload-area { | |
| border: 2px dashed var(--border); | |
| border-radius: var(--radius); | |
| padding: 2rem; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| position: relative; | |
| background: rgba(51, 65, 85, 0.3); | |
| } | |
| .upload-area:hover, .upload-area.dragover { | |
| border-color: var(--primary); | |
| background: rgba(99, 102, 241, 0.1); | |
| } | |
| .upload-icon { | |
| font-size: 3rem; | |
| color: var(--text-muted); | |
| margin-bottom: 1rem; | |
| } | |
| .upload-text { | |
| color: var(--text-muted); | |
| font-size: 0.9rem; | |
| } | |
| .upload-text strong { color: var(--primary); } | |
| #fileInput { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| opacity: 0; | |
| cursor: pointer; | |
| } | |
| /* File Info */ | |
| .file-info { | |
| display: none; | |
| background: var(--bg-input); | |
| padding: 1rem; | |
| border-radius: var(--radius); | |
| animation: fadeIn 0.3s ease; | |
| } | |
| .file-info.active { display: block; } | |
| .file-row { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 0.5rem; | |
| font-size: 0.9rem; | |
| } | |
| .file-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 70%; } | |
| .file-size { color: var(--text-muted); } | |
| /* Controls */ | |
| .control-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| .control-header { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.85rem; | |
| color: var(--text-muted); | |
| } | |
| input[type="range"] { | |
| -webkit-appearance: none; | |
| width: 100%; | |
| height: 6px; | |
| background: var(--bg-input); | |
| border-radius: 3px; | |
| cursor: pointer; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| background: var(--primary); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: transform 0.1s; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); } | |
| .resolution-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 0.5rem; | |
| margin-top: 0.5rem; | |
| } | |
| .res-btn { | |
| background: var(--bg-input); | |
| border: 1px solid transparent; | |
| color: var(--text-muted); | |
| padding: 0.5rem; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| transition: all 0.2s; | |
| } | |
| .res-btn:hover { background: var(--border); } | |
| .res-btn.active { background: rgba(99, 102, 241, 0.2); color: var(--primary); border-color: var(--primary); } | |
| select { | |
| width: 100%; | |
| padding: 0.75rem; | |
| background: var(--bg-input); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| color: var(--text-main); | |
| font-family: inherit; | |
| cursor: pointer; | |
| } | |
| /* Action Button */ | |
| .btn-primary { | |
| width: 100%; | |
| padding: 1rem; | |
| background: var(--primary); | |
| color: white; | |
| border: none; | |
| border-radius: var(--radius); | |
| font-size: 1rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 0.5rem; | |
| transition: background 0.3s; | |
| } | |
| .btn-primary:hover { background: var(--primary-hover); } | |
| .btn-primary:disabled { background: var(--bg-input); cursor: not-allowed; opacity: 0.7; } | |
| /* Terminal / Logs */ | |
| .terminal-container { | |
| background: #000; | |
| border-radius: var(--radius); | |
| border: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| height: 100%; | |
| min-height: 400px; | |
| overflow: hidden; | |
| } | |
| .terminal-header { | |
| background: var(--bg-input); | |
| padding: 0.75rem 1rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .terminal-title { font-size: 0.85rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; } | |
| .terminal-actions button { | |
| background: transparent; | |
| border: none; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| padding: 0.25rem; | |
| } | |
| .terminal-actions button:hover { color: var(--text-main); } | |
| #terminal { | |
| flex: 1; | |
| padding: 1rem; | |
| font-family: 'Fira Code', monospace; | |
| font-size: 0.85rem; | |
| overflow-y: auto; | |
| color: #d4d4d4; | |
| line-height: 1.5; | |
| } | |
| .log-line { margin-bottom: 0.25rem; word-wrap: break-word; } | |
| .log-info { color: #60a5fa; } | |
| .log-success { color: var(--success); } | |
| .log-error { color: var(--error); } | |
| .log-process { color: #fbbf24; } | |
| .log-progress { color: var(--text-muted); } | |
| /* Progress Bar */ | |
| .progress-container { | |
| margin-top: 1rem; | |
| } | |
| .progress-labels { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.85rem; | |
| margin-bottom: 0.5rem; | |
| color: var(--text-muted); | |
| } | |
| .progress-track { | |
| height: 8px; | |
| background: var(--bg-input); | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| width: 0%; | |
| background: linear-gradient(90deg, var(--primary), #818cf8); | |
| transition: width 0.3s ease; | |
| } | |
| /* Footer */ | |
| footer { | |
| text-align: center; | |
| padding: 2rem; | |
| color: var(--text-muted); | |
| font-size: 0.85rem; | |
| margin-top: auto; | |
| border-top: 1px solid var(--border); | |
| } | |
| footer a { | |
| color: var(--primary); | |
| text-decoration: none; | |
| font-weight: 600; | |
| } | |
| /* Utilities */ | |
| .hidden { display: none ; } | |
| .badge { | |
| font-size: 0.7rem; | |
| padding: 0.2rem 0.5rem; | |
| border-radius: 4px; | |
| background: var(--bg-input); | |
| color: var(--text-muted); | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(5px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* Scrollbar */ | |
| ::-webkit-scrollbar { width: 8px; height: 8px; } | |
| ::-webkit-scrollbar-track { background: var(--bg-dark); } | |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="logo"> | |
| <i class="fa-solid fa-layer-group"></i> | |
| Anycoder | |
| </a> | |
| <div style="display: flex; gap: 1rem; align-items: center;"> | |
| <span class="badge" style="background: rgba(16, 185, 129, 0.1); color: var(--success);">WebAssembly Ready</span> | |
| </div> | |
| </header> | |
| <main> | |
| <!-- Left Panel: Controls --> | |
| <section class="panel"> | |
| <h2><i class="fa-solid fa-sliders"></i> Settings</h2> | |
| <!-- File Input --> | |
| <div class="upload-area" id="dropZone"> | |
| <i class="fa-solid fa-cloud-arrow-up upload-icon"></i> | |
| <div class="upload-text"> | |
| <strong>Click to upload</strong> or drag and drop<br> | |
| MP4, MOV, AVI, MKV (Max 10GB+) | |
| </div> | |
| <input type="file" id="fileInput" accept="video/*"> | |
| </div> | |
| <div class="file-info" id="fileInfo"> | |
| <div class="file-row"> | |
| <span class="file-name" id="fileName">video.mp4</span> | |
| <span class="file-size" id="fileSize">0 GB</span> | |
| </div> | |
| <div class="file-row"> | |
| <span class="badge" id="fileDuration">Duration: --:--</span> | |
| <span class="badge" id="fileRes">Original: --x--</span> | |
| </div> | |
| </div> | |
| <!-- Resolution Controls --> | |
| <div class="control-group"> | |
| <div class="control-header"> | |
| <span>Resolution</span> | |
| <span id="resValue">100%</span> | |
| </div> | |
| <input type="range" id="resolutionSlider" min="10" max="100" value="100"> | |
| <div class="resolution-grid"> | |
| <button class="res-btn" data-val="0.5">0.5x</button> | |
| <button class="res-btn active" data-val="1.0">1.0x</button> | |
| <button class="res-btn" data-val="0.25">0.25x</button> | |
| </div> | |
| </div> | |
| <!-- Bitrate Controls --> | |
| <div class="control-group"> | |
| <div class="control-header"> | |
| <span>Target Bitrate</span> | |
| <span id="bitrateValue">2.0 Mbps</span> | |
| </div> | |
| <input type="range" id="bitrateSlider" min="500" max="10000" step="100" value="2000"> | |
| <div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;"> | |
| Lower values = Smaller file size, potentially lower quality. | |
| </div> | |
| </div> | |
| <!-- Codec Selection --> | |
| <div class="control-group"> | |
| <label style="font-size: 0.9rem; color: var(--text-muted);">Video Codec</label> | |
| <select id="codecSelect"> | |
| <option value="libx264">H.264 (Best Compatibility)</option> | |
| <option value="libx265">H.265 (HEVC - Smaller, CPU Heavy)</option> | |
| <option value="libvpx-vp9">VP9 (Google, Good Compression)</option> | |
| <option value="copy">Copy Stream (No Re-encoding)</option> | |
| </select> | |
| </div> | |
| <!-- Action Button --> | |
| <button id="convertBtn" class="btn-primary" disabled> | |
| <i class="fa-solid fa-play"></i> Start Conversion | |
| </button> | |
| </section> | |
| <!-- Right Panel: Terminal & Progress --> | |
| <section class="panel" style="padding: 0;"> | |
| <div class="terminal-container"> | |
| <div class="terminal-header"> | |
| <span class="terminal-title">FFmpeg Terminal</span> | |
| <div class="terminal-actions"> | |
| <button id="clearLogs" title="Clear Logs"><i class="fa-solid fa-trash"></i></button> | |
| <button id="downloadBtn" title="Download Result" class="hidden"><i class="fa-solid fa-download"></i></button> | |
| </div> | |
| </div> | |
| <div id="terminal"> | |
| <div class="log-line log-info">[System] Ready. Waiting for input...</div> | |
| <div class="log-line log-info">[System] FFmpeg.wasm loaded.</div> | |
| </div> | |
| </div> | |
| <div class="progress-container"> | |
| <div class="progress-labels"> | |
| <span id="progressPercent">0%</span> | |
| <span id="progressTime">ETA: --:--</span> | |
| </div> | |
| <div class="progress-track"> | |
| <div class="progress-fill" id="progressBar"></div> | |
| </div> | |
| <div id="statusText" style="text-align: center; font-size: 0.85rem; color: var(--text-muted); margin-top: 0.5rem;"> | |
| Idle | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <footer> | |
| <p>Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank">anycoder</a></p> | |
| </footer> | |
| <!-- Import Map for FFmpeg.wasm --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "ffmpeg": "https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/esm/index.js", | |
| "@ffmpeg/util": "https://unpkg.com/@ffmpeg/util@0.12.1/dist/esm/index.js" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import { FFmpeg } from 'ffmpeg'; | |
| import { fetchFile, toBlobURL } from '@ffmpeg/util'; | |
| // --- DOM Elements --- | |
| const fileInput = document.getElementById('fileInput'); | |
| const dropZone = document.getElementById('dropZone'); | |
| const fileInfo = document.getElementById('fileInfo'); | |
| const fileNameEl = document.getElementById('fileName'); | |
| const fileSizeEl = document.getElementById('fileSize'); | |
| const fileDurationEl = document.getElementById('fileDuration'); | |
| const fileResEl = document.getElementById('fileRes'); | |
| const resolutionSlider = document.getElementById('resolutionSlider'); | |
| const resValue = document.getElementById('resValue'); | |
| const resBtns = document.querySelectorAll('.res-btn'); | |
| const bitrateSlider = document.getElementById('bitrateSlider'); | |
| const bitrateValue = document.getElementById('bitrateValue'); | |
| const codecSelect = document.getElementById('codecSelect'); | |
| const convertBtn = document.getElementById('convertBtn'); | |
| const terminal = document.getElementById('terminal'); | |
| const progressBar = document.getElementById('progressBar'); | |
| const progressPercent = document.getElementById('progressPercent'); | |
| const progressTime = document.getElementById('progressTime'); | |
| const statusText = document.getElementById('statusText'); | |
| const downloadBtn = document.getElementById('downloadBtn'); | |
| const clearLogsBtn = document.getElementById('clearLogs'); | |
| // --- State --- | |
| let ffmpeg = null; | |
| let loaded = false; | |
| let selectedFile = null; | |
| let originalWidth = 0; | |
| let originalHeight = 0; | |
| let isConverting = false; | |
| // --- Initialization --- | |
| async function loadFFmpeg() { | |
| if (loaded) return; | |
| log('info', 'Loading FFmpeg Core...'); | |
| ffmpeg = new FFmpeg(); | |
| // Event Listeners for FFmpeg progress | |
| ffmpeg.on('log', ({ message }) => { | |
| log('process', message); | |
| }); | |
| ffmpeg.on('progress', ({ progress, time }) => { | |
| if (time > 0) { | |
| const percent = Math.round(progress * 100); | |
| progressBar.style.width = `${percent}%`; | |
| progressPercent.innerText = `${percent}%`; | |
| // Simple ETA calculation | |
| if (progress > 0 && progress < 1) { | |
| const totalSeconds = (time / progress); | |
| const remainingSeconds = totalSeconds - time; | |
| progressTime.innerText = `ETA: ${formatTime(remainingSeconds)}`; | |
| } | |
| } | |
| }); | |
| const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'; | |
| try { | |
| await ffmpeg.load({ | |
| coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'), | |
| wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'), | |
| }); | |
| loaded = true; | |
| log('success', 'FFmpeg Core Loaded Successfully!'); | |
| log('info', 'Ready to process large files.'); | |
| } catch (error) { | |
| log('error', 'Failed to load FFmpeg: ' + error.message); | |
| console.error(error); | |
| } | |
| } | |
| // --- File Handling --- | |
| 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 formatTime(seconds) { | |
| const h = Math.floor(seconds / 3600); | |
| const m = Math.floor((seconds % 3600) / 60); | |
| const s = Math.floor(seconds % 60); | |
| if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; | |
| return `${m}:${s.toString().padStart(2, '0')}`; | |
| } | |
| function handleFile(file) { | |
| if (!file.type.startsWith('video/')) { | |
| log('error', 'Please select a valid video file.'); | |
| return; | |
| } | |
| if (file.size > 10 * 1024 * 1024 * 1024) { | |
| log('error', 'File is too large (Max 10GB supported).'); | |
| return; | |
| } | |
| selectedFile = file; | |
| fileNameEl.textContent = file.name; | |
| fileSizeEl.textContent = formatBytes(file.size); | |
| fileInfo.classList.add('active'); | |
| dropZone.classList.add('hidden'); | |
| convertBtn.disabled = false; | |
| // Basic metadata extraction (using a small helper or just assuming standard) | |
| // Since we can't easily get duration/width/height without re-encoding first or using ffprobe | |
| // We will set defaults and update if possible. | |
| fileResEl.textContent = "Detecting..."; | |
| // We use a simple timeout to simulate initial load | |
| setTimeout(() => { | |
| fileResEl.textContent = "Original: --x-- (Detecting via FFprobe)"; | |
| // Note: Real width/height detection requires ffprobe, | |
| // but we can proceed with the current settings. | |
| }, 500); | |
| } | |
| 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('dragover'); | |
| }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); | |
| dropZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('dragover'); | |
| if (e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]); | |
| }); | |
| // --- Controls Logic --- | |
| resolutionSlider.addEventListener('input', (e) => { | |
| resValue.textContent = `${e.target.value}%`; | |
| resBtns.forEach(btn => btn.classList.remove('active')); | |
| }); | |
| resBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| resBtns.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| const scale = parseFloat(btn.dataset.val); | |
| resolutionSlider.value = scale * 100; | |
| resValue.textContent = `${scale}x`; | |
| }); | |
| }); | |
| bitrateSlider.addEventListener('input', (e) => { | |
| bitrateValue.textContent = `${(e.target.value / 1000).toFixed(1)} Mbps`; | |
| }); | |
| // --- Conversion Logic --- | |
| convertBtn.addEventListener('click', async () => { | |
| if (!selectedFile || !loaded) return; | |
| isConverting = true; | |
| convertBtn.disabled = true; | |
| convertBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Converting...'; | |
| statusText.textContent = "Initializing FFmpeg..."; | |
| downloadBtn.classList.add('hidden'); | |
| progressBar.style.width = '0%'; | |
| // Create output filename | |
| const nameParts = selectedFile.name.split('.'); | |
| const ext = nameParts.pop(); | |
| const baseName = nameParts.join('.'); | |
| const outputName = `${baseName}_converted.mp4`; | |
| try { | |
| // 1. Load File into FFmpeg memory | |
| log('info', `Loading file into memory (${formatBytes(selectedFile.size)})...`); | |
| statusText.textContent = "Loading File..."; | |
| await ffmpeg.writeFile('input.mp4', await fetchFile(selectedFile)); | |
| // 2. Determine Output Dimensions | |
| const scale = parseFloat(resolutionSlider.value) / 100; | |
| const newWidth = Math.round(originalWidth * scale); | |
| const newHeight = Math.round(originalHeight * scale); | |
| // If scale is 1.0 (100%), FFmpeg handles it automatically with -vf scale=iw:-2 | |
| let scaleFilter = ''; | |
| if (scale < 1) { | |
| scaleFilter = `-vf scale=${newWidth}:-2`; // -2 ensures height is even | |
| } | |
| // 3. Determine Codec | |
| const codec = codecSelect.value; | |
| let videoCodecArgs = ''; | |
| if (codec === 'libx264') videoCodecArgs = '-preset veryfast -crf 23'; | |
| if (codec === 'libx265') videoCodecArgs = '-preset veryfast -crf 28'; // H265 needs higher CRF for same quality | |
| if (codec === 'libvpx-vp9') videoCodecArgs = '-b:v 1M -quality good -speed 4'; | |
| if (codec === 'copy') videoCodecArgs = '-c:v copy'; // No re-encoding | |
| // 4. Construct FFmpeg Command | |
| // -movflags +faststart improves web playback performance | |
| const bitrate = parseInt(bitrateSlider.value); | |
| const args = [ | |
| '-i', 'input.mp4', | |
| '-c:a', 'aac', // Force AAC audio for compatibility | |
| '-b:a', '128k', | |
| '-b:v', `${bitrate}k`, | |
| '-movflags', '+faststart', | |
| '-y', // Overwrite output | |
| outputName | |
| ]; | |
| if (videoCodecArgs) args.unshift(...videoCodecArgs.split(' ')); | |
| if (scaleFilter) args.unshift(...scaleFilter.split(' ')); | |
| log('info', `Starting conversion: ${args.join(' ')}`); | |
| // 5. Run FFmpeg | |
| statusText.textContent = "Processing Video..."; | |
| await ffmpeg.exec(args); | |
| // 6. Read Result | |
| statusText.textContent = "Writing output file..."; | |
| const data = await ffmpeg.readFile(outputName); | |
| // 7. Download | |
| const blob = new Blob([data.buffer], { type: 'video/mp4' }); | |
| const url = URL.createObjectURL(blob); | |
| downloadBtn.href = url; | |
| downloadBtn.download = outputName; | |
| downloadBtn.classList.remove('hidden'); | |
| log('success', `Conversion Complete! Output size: ${formatBytes(blob.size)}`); | |
| log('success', 'Click the download button to save.'); | |
| statusText.textContent = "Conversion Complete"; | |
| convertBtn.innerHTML = '<i class="fa-solid fa-check"></i> Done'; | |
| convertBtn.disabled = false; | |
| } catch (error) { | |
| log('error', `Error: ${error.message}`); | |
| statusText.textContent = "Conversion Failed"; | |
| convertBtn.innerHTML = '<i class="fa-solid fa-play"></i> Retry'; | |
| convertBtn.disabled = false; | |
| } finally { | |
| isConverting = false; | |
| } | |
| }); | |
| // --- Terminal & UI Helpers --- | |
| function log(type, message) { | |
| const div = document.createElement('div'); | |
| div.className = `log-line log-${type}`; | |
| // Timestamp | |
| const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute:'2-digit', second:'2-digit' }); | |
| div.innerHTML = `<span style="opacity:0.5">[${time}]</span> ${message}`; | |
| terminal.appendChild(div); | |
| terminal.scrollTop = terminal.scrollHeight; | |
| } | |
| clearLogsBtn.addEventListener('click', () => { | |
| terminal.innerHTML = '<div class="log-line log-info">[System] Logs cleared.</div>'; | |
| }); | |
| downloadBtn.addEventListener('click', () => { | |
| // Small delay to ensure browser recognizes the new blob | |
| setTimeout(() => { | |
| log('info', 'Download started.'); | |
| }, 100); | |
| }); | |
| // Initialize on Load | |
| window.addEventListener('load', loadFFmpeg); | |
| </script> | |
| </body> | |
| </html> |