No articles found
Try different keywords or browse our categories
FFMPEG WASM Project: Build a Video Converter in Browser with HTML, CSS & JavaScript
Learn how to integrate FFmpeg.wasm in your web application using vanilla JavaScript. Convert video formats, compress files, and process videos entirely in the browser.
Build a powerful video converter that runs entirely in the browser using FFmpeg.wasm with vanilla JavaScript. No frameworks, no backend - just pure HTML, CSS, and JavaScript to convert video formats and compress files client-side.
What We’ll Build
A complete video converter application with:
- Upload videos via drag-and-drop or file picker
- Convert between MP4, WebM, AVI, MOV formats
- Compress videos with quality presets
- Real-time progress tracking
- Automatic download of processed videos
- Modern, responsive UI
Project Structure
Here’s the complete directory structure:
ffmpeg-video-converter/
├── index.html
├── css/
│ ├── style.css
│ ├── upload.css
│ ├── converter.css
│ └── modal.css
├── js/
│ ├── app.js
│ ├── ffmpeg-loader.js
│ ├── video-processor.js
│ ├── ui-controller.js
│ └── utils.js
└── assets/
└── icons/
└── video-icon.svg
Article Sections
This tutorial covers:
- Project Setup - Dependencies and configuration
- HTML Structure - Semantic markup and layout
- CSS Styling - Modern design with animations
- FFmpeg Integration - Loading and initializing FFmpeg.wasm
- Video Processing - Format conversion and compression logic
- UI Controls - Upload, progress, and download handlers
- Error Handling - Robust error management
- Testing & Deployment - Running and hosting the app
Step 1: HTML Structure
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Converter - FFmpeg.wasm</title>
<!-- Stylesheets -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/upload.css">
<link rel="stylesheet" href="css/converter.css">
<link rel="stylesheet" href="css/modal.css">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="container">
<div class="header-content">
<div class="logo">
<i data-lucide="film"></i>
<h1>Video Converter</h1>
</div>
<div class="status-badge" id="ffmpegStatus">
<i data-lucide="loader"></i>
<span>Loading...</span>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main">
<div class="container">
<!-- Loading Screen -->
<div id="loadingScreen" class="loading-screen">
<div class="spinner"></div>
<h2>Initializing FFmpeg</h2>
<p>Loading video processing engine...</p>
<div class="progress-bar">
<div class="progress-fill" id="loadProgress"></div>
</div>
</div>
<!-- Upload Section -->
<div id="uploadSection" class="upload-section hidden">
<div class="upload-card">
<div class="upload-area" id="uploadArea">
<input
type="file"
id="fileInput"
accept="video/*"
style="display: none;"
>
<div class="upload-icon">
<i data-lucide="upload"></i>
<i data-lucide="film" class="film-icon"></i>
</div>
<h2>Drop your video here</h2>
<p class="upload-hint">or click to browse</p>
<p class="upload-formats">Supports MP4, WebM, AVI, MOV, MKV</p>
</div>
</div>
<!-- Info Box -->
<div class="info-box">
<i data-lucide="info"></i>
<div>
<h3>How It Works</h3>
<ul>
<li>All processing happens in your browser</li>
<li>No files are uploaded to any server</li>
<li>Convert between popular video formats</li>
<li>Compress videos to reduce file size</li>
<li>Fast processing with WebAssembly</li>
</ul>
</div>
</div>
</div>
<!-- Converter Section -->
<div id="converterSection" class="converter-section hidden">
<!-- Video Info Card -->
<div class="video-info-card">
<div class="video-preview">
<video id="videoPreview" controls></video>
</div>
<div class="video-details">
<h3 id="videoName">video.mp4</h3>
<div class="video-meta">
<div class="meta-item">
<i data-lucide="clock"></i>
<span id="videoDuration">00:00</span>
</div>
<div class="meta-item">
<i data-lucide="maximize"></i>
<span id="videoResolution">1920x1080</span>
</div>
<div class="meta-item">
<i data-lucide="hard-drive"></i>
<span id="videoSize">0 MB</span>
</div>
</div>
</div>
</div>
<!-- Conversion Options -->
<div class="options-card">
<h3>Conversion Settings</h3>
<div class="option-group">
<label for="outputFormat">Output Format</label>
<select id="outputFormat">
<option value="mp4">MP4 (H.264)</option>
<option value="webm">WebM (VP9)</option>
<option value="avi">AVI</option>
<option value="mov">MOV</option>
<option value="mkv">MKV</option>
</select>
</div>
<div class="option-group">
<label for="quality">Quality / Compression</label>
<select id="quality">
<option value="high">High Quality (Larger File)</option>
<option value="medium" selected>Medium Quality (Balanced)</option>
<option value="low">Low Quality (Smaller File)</option>
<option value="compress">Maximum Compression</option>
</select>
</div>
<div class="option-group">
<label for="resolution">Resolution</label>
<select id="resolution">
<option value="original" selected>Keep Original</option>
<option value="1920x1080">1080p (1920x1080)</option>
<option value="1280x720">720p (1280x720)</option>
<option value="854x480">480p (854x480)</option>
<option value="640x360">360p (640x360)</option>
</select>
</div>
<div class="action-buttons">
<button id="newVideoBtn" class="btn-secondary">
<i data-lucide="upload"></i>
New Video
</button>
<button id="convertBtn" class="btn-primary">
<i data-lucide="zap"></i>
Convert Video
</button>
</div>
</div>
</div>
</div>
</main>
<!-- Progress Modal -->
<div id="progressModal" class="modal hidden">
<div class="modal-content">
<div class="progress-circle">
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" class="progress-bg"></circle>
<circle
cx="50"
cy="50"
r="45"
class="progress-bar-circle"
id="progressCircle"
></circle>
</svg>
<div class="progress-text" id="progressText">0%</div>
</div>
<h3 id="progressTitle">Converting Video</h3>
<p id="progressMessage">Please wait while we process your video...</p>
</div>
</div>
<!-- Success Modal -->
<div id="successModal" class="modal hidden">
<div class="modal-content">
<div class="success-icon">
<i data-lucide="check-circle"></i>
</div>
<h3>Conversion Complete!</h3>
<p>Your video has been processed successfully.</p>
<p class="file-info" id="outputFileInfo"></p>
<button id="closeSuccessBtn" class="btn-primary">
<i data-lucide="check"></i>
Done
</button>
</div>
</div>
<!-- Error Modal -->
<div id="errorModal" class="modal hidden">
<div class="modal-content">
<div class="error-icon">
<i data-lucide="alert-circle"></i>
</div>
<h3>Conversion Failed</h3>
<p id="errorMessage">An error occurred during processing.</p>
<button id="closeErrorBtn" class="btn-secondary">
<i data-lucide="x"></i>
Close
</button>
</div>
</div>
<!-- Scripts -->
<script type="module" src="js/app.js"></script>
<script>
// Initialize Lucide icons
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
});
</script>
</body>
</html>
Step 2: Base Styling
css/style.css:
/* CSS Reset & Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--secondary: #8b5cf6;
--success: #10b981;
--error: #ef4444;
--warning: #f59e0b;
--bg-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--card-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
--card-shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.15);
--text-primary: #1e293b;
--text-secondary: #64748b;
--border: #e2e8f0;
--radius: 12px;
--radius-lg: 16px;
--spacing: 24px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--bg-gradient);
min-height: 100vh;
color: var(--text-primary);
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 0 var(--spacing);
}
.hidden {
display: none !important;
}
/* Header */
.header {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(10px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-primary);
}
.logo svg {
width: 32px;
height: 32px;
color: var(--primary);
}
.logo h1 {
font-size: 24px;
font-weight: 700;
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.status-badge.loading {
background: #fef3c7;
color: #92400e;
}
.status-badge.ready {
background: #d1fae5;
color: #065f46;
}
.status-badge.error {
background: #fee2e2;
color: #991b1b;
}
.status-badge svg {
width: 18px;
height: 18px;
}
/* Main */
.main {
padding: 40px 0;
min-height: calc(100vh - 80px);
}
/* Loading Screen */
.loading-screen {
text-align: center;
padding: 80px 20px;
color: white;
}
.spinner {
width: 60px;
height: 60px;
border: 5px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 24px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-screen h2 {
font-size: 28px;
margin-bottom: 12px;
}
.loading-screen p {
font-size: 16px;
opacity: 0.9;
margin-bottom: 24px;
}
.progress-bar {
max-width: 400px;
height: 8px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
overflow: hidden;
margin: 0 auto;
}
.progress-fill {
height: 100%;
background: white;
transition: width 0.3s;
width: 0%;
}
/* Buttons */
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 28px;
border: none;
border-radius: var(--radius);
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: white;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, var(--primary-dark) 0%, #7c3aed 100%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
}
.btn-secondary {
background: #f1f5f9;
color: #475569;
border: 2px solid var(--border);
}
.btn-secondary:hover:not(:disabled) {
background: #e2e8f0;
border-color: #cbd5e1;
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Info Box */
.info-box {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing);
display: flex;
gap: 20px;
box-shadow: var(--card-shadow);
margin-top: 24px;
}
.info-box svg {
color: var(--primary);
width: 24px;
height: 24px;
flex-shrink: 0;
}
.info-box h3 {
margin-bottom: 12px;
font-size: 18px;
}
.info-box ul {
margin: 0 0 0 20px;
color: var(--text-secondary);
line-height: 1.8;
}
.info-box li {
margin-bottom: 6px;
}
/* Responsive */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
}
.container {
padding: 0 16px;
}
.main {
padding: 24px 0;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
}
Step 3: Upload Section Styling
css/upload.css:
.upload-section {
max-width: 800px;
margin: 0 auto;
}
.upload-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing);
box-shadow: var(--card-shadow);
}
.upload-area {
border: 3px dashed var(--primary);
border-radius: var(--radius-lg);
padding: 60px 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
position: relative;
overflow: hidden;
}
.upload-area::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%);
animation: pulse 3s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 0.5;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
.upload-area:hover {
border-color: var(--primary-dark);
background: linear-gradient(135deg, #e0f2fe 0%, #bae6fd 100%);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.2);
}
.upload-area.dragover {
border-color: var(--secondary);
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
transform: scale(1.02);
}
.upload-icon {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-bottom: 24px;
position: relative;
z-index: 1;
}
.upload-icon svg {
width: 48px;
height: 48px;
color: var(--primary);
}
.upload-icon .film-icon {
color: var(--secondary);
}
.upload-area h2 {
margin: 0 0 12px 0;
font-size: 22px;
font-weight: 700;
position: relative;
z-index: 1;
}
.upload-hint {
color: var(--text-secondary);
font-size: 16px;
margin: 0 0 16px 0;
position: relative;
z-index: 1;
}
.upload-formats {
font-size: 14px;
color: var(--text-secondary);
padding: 8px 16px;
background: rgba(59, 130, 246, 0.1);
border-radius: 20px;
display: inline-block;
position: relative;
z-index: 1;
}
/* Processing State */
.upload-area.processing {
pointer-events: none;
opacity: 0.7;
}
.upload-area .spinner-small {
width: 40px;
height: 40px;
border: 4px solid #e0f2fe;
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
Step 4: Converter Section Styling
css/converter.css:
.converter-section {
display: grid;
gap: 24px;
max-width: 900px;
margin: 0 auto;
}
/* Video Info Card */
.video-info-card {
background: white;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--card-shadow);
}
.video-preview {
width: 100%;
background: #000;
position: relative;
}
.video-preview video {
width: 100%;
max-height: 400px;
display: block;
object-fit: contain;
}
.video-details {
padding: var(--spacing);
}
.video-details h3 {
font-size: 20px;
margin-bottom: 16px;
color: var(--text-primary);
word-break: break-word;
}
.video-meta {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
}
.meta-item svg {
width: 18px;
height: 18px;
color: var(--primary);
}
/* Options Card */
.options-card {
background: white;
border-radius: var(--radius-lg);
padding: var(--spacing);
box-shadow: var(--card-shadow);
}
.options-card h3 {
font-size: 20px;
margin-bottom: 24px;
color: var(--text-primary);
}
.option-group {
margin-bottom: 20px;
}
.option-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.option-group select {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
background: white;
cursor: pointer;
transition: all 0.2s;
}
.option-group select:hover:not(:disabled) {
border-color: var(--primary);
}
.option-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.option-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Action Buttons */
.action-buttons {
display: grid;
grid-template-columns: 180px 1fr;
gap: 12px;
margin-top: 32px;
}
@media (max-width: 640px) {
.action-buttons {
grid-template-columns: 1fr;
}
.video-meta {
flex-direction: column;
gap: 12px;
}
}
Step 5: Modal Styling
css/modal.css:
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal.hidden {
display: none;
}
.modal-content {
background: white;
border-radius: 20px;
padding: 48px;
max-width: 500px;
width: 90%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Progress Circle */
.progress-circle {
position: relative;
width: 150px;
height: 150px;
margin: 0 auto 24px;
}
.progress-circle svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.progress-bg {
fill: none;
stroke: #e2e8f0;
stroke-width: 8;
}
.progress-bar-circle {
fill: none;
stroke: var(--primary);
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 282.7;
stroke-dashoffset: 282.7;
transition: stroke-dashoffset 0.3s ease;
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 36px;
font-weight: 700;
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Success Icon */
.success-icon {
width: 120px;
height: 120px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: var(--success);
}
.success-icon svg {
width: 64px;
height: 64px;
}
/* Error Icon */
.error-icon {
width: 120px;
height: 120px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: linear-gradient(135deg, #fee2e2, #fecaca);
color: var(--error);
}
.error-icon svg {
width: 64px;
height: 64px;
}
.modal-content h3 {
margin: 0 0 12px 0;
font-size: 24px;
color: var(--text-primary);
}
.modal-content p {
margin: 0 0 24px 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.6;
}
.file-info {
padding: 12px;
background: #f1f5f9;
border-radius: var(--radius);
font-family: 'Courier New', monospace;
font-size: 14px;
color: var(--text-primary);
}
Step 6: Utility Functions
js/utils.js:
// Format time in seconds to MM:SS or HH:MM:SS
export function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Format file size in bytes to human readable
export function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// Get video metadata
export function getVideoMetadata(file) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
const metadata = {
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight
};
URL.revokeObjectURL(video.src);
resolve(metadata);
};
video.onerror = () => {
reject(new Error('Failed to load video metadata'));
};
video.src = URL.createObjectURL(file);
});
}
// Download blob as file
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up after a delay
setTimeout(() => URL.revokeObjectURL(url), 100);
}
// Validate video file
export function validateVideoFile(file) {
const validTypes = [
'video/mp4',
'video/webm',
'video/ogg',
'video/quicktime',
'video/x-msvideo',
'video/x-matroska'
];
if (!validTypes.includes(file.type) && !file.name.match(/\.(mp4|webm|ogg|mov|avi|mkv)$/i)) {
throw new Error('Invalid file type. Please upload a video file.');
}
const maxSize = 500 * 1024 * 1024; // 500MB
if (file.size > maxSize) {
throw new Error('File too large. Maximum size is 500MB.');
}
return true;
}
// Generate output filename
export function generateOutputFilename(originalName, format) {
const nameWithoutExt = originalName.replace(/\.[^/.]+$/, '');
const timestamp = Date.now();
return `${nameWithoutExt}-converted-${timestamp}.${format}`;
}
Step 7: FFmpeg Loader
js/ffmpeg-loader.js:
import { FFmpeg } from 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.10/dist/esm/ffmpeg.js';
import { toBlobURL } from 'https://unpkg.com/@ffmpeg/util@0.12.1/dist/esm/index.js';
let ffmpegInstance = null;
let isLoaded = false;
// Load FFmpeg
export async function loadFFmpeg(onProgress) {
if (ffmpegInstance && isLoaded) {
return ffmpegInstance;
}
try {
const ffmpeg = new FFmpeg();
// Log handler
ffmpeg.on('log', ({ message }) => {
console.log('[FFmpeg]', message);
});
// Progress handler
ffmpeg.on('progress', ({ progress }) => {
if (onProgress) {
onProgress(Math.round(progress * 100));
}
});
// Load FFmpeg core
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
});
ffmpegInstance = ffmpeg;
isLoaded = true;
return ffmpeg;
} catch (error) {
console.error('Failed to load FFmpeg:', error);
throw new Error('Failed to initialize video processor');
}
}
// Get FFmpeg instance
export function getFFmpeg() {
if (!ffmpegInstance || !isLoaded) {
throw new Error('FFmpeg not loaded. Call loadFFmpeg() first.');
}
return ffmpegInstance;
}
// Check if FFmpeg is loaded
export function isFFmpegLoaded() {
return isLoaded && ffmpegInstance !== null;
}
Step 8: Video Processor
js/video-processor.js:
import { getFFmpeg } from './ffmpeg-loader.js';
import { fetchFile } from 'https://unpkg.com/@ffmpeg/util@0.12.1/dist/esm/index.js';
// Quality presets
const QUALITY_PRESETS = {
high: { crf: '18', preset: 'slow', bitrate: '5000k' },
medium: { crf: '23', preset: 'medium', bitrate: '2500k' },
low: { crf: '28', preset: 'fast', bitrate: '1000k' },
compress: { crf: '32', preset: 'ultrafast', bitrate: '500k' }
};
// Convert video
export async function convertVideo(file, options) {
const ffmpeg = getFFmpeg();
const inputName = 'input.mp4';
const outputName = `output.${options.format}`;
try {
// Write input file
await ffmpeg.writeFile(inputName, await fetchFile(file));
// Build FFmpeg command
const command = buildConvertCommand(inputName, outputName, options);
console.log('FFmpeg command:', command.join(' '));
// Execute conversion
await ffmpeg.exec(command);
// Read output file
const data = await ffmpeg.readFile(outputName);
// Clean up
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
// Return as blob
return new Blob([data.buffer], { type: `video/${options.format}` });
} catch (error) {
console.error('Conversion error:', error);
throw new Error(`Conversion failed: ${error.message}`);
}
}
// Build FFmpeg command
function buildConvertCommand(input, output, options) {
const quality = QUALITY_PRESETS[options.quality] || QUALITY_PRESETS.medium;
const command = ['-i', input];
// Video codec based on format
if (options.format === 'webm') {
command.push('-c:v', 'libvpx-vp9');
command.push('-b:v', quality.bitrate);
} else if (options.format === 'avi') {
command.push('-c:v', 'mpeg4');
} else {
command.push('-c:v', 'libx264');
command.push('-crf', quality.crf);
command.push('-preset', quality.preset);
}
// Audio codec
if (options.format === 'webm') {
command.push('-c:a', 'libopus');
} else {
command.push('-c:a', 'aac');
command.push('-b:a', '128k');
}
// Resolution scaling
if (options.resolution && options.resolution !== 'original') {
const [width, height] = options.resolution.split('x');
command.push('-vf', `scale=${width}:${height}`);
}
// Output
command.push(output);
return command;
}
// Get format info
export function getFormatInfo(format) {
const formats = {
mp4: { name: 'MP4', codec: 'H.264', ext: 'mp4' },
webm: { name: 'WebM', codec: 'VP9', ext: 'webm' },
avi: { name: 'AVI', codec: 'MPEG-4', ext: 'avi' },
mov: { name: 'MOV', codec: 'H.264', ext: 'mov' },
mkv: { name: 'MKV', codec: 'H.264', ext: 'mkv' }
};
return formats[format] || formats.mp4;
}
Step 9: UI Controller
js/ui-controller.js:
import { formatTime, formatFileSize, getVideoMetadata } from './utils.js';
// Show/hide sections
export function showSection(sectionId) {
const sections = ['loadingScreen', 'uploadSection', 'converterSection'];
sections.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.classList.toggle('hidden', id !== sectionId);
}
});
}
// Update FFmpeg status badge
export function updateFFmpegStatus(status, message) {
const badge = document.getElementById('ffmpegStatus');
if (!badge) return;
const icon = badge.querySelector('i');
const text = badge.querySelector('span');
badge.className = `status-badge ${status}`;
text.textContent = message;
const icons = {
loading: 'loader',
ready: 'check-circle',
error: 'alert-circle'
};
if (icon && icons[status]) {
icon.setAttribute('data-lucide', icons[status]);
lucide.createIcons();
}
}
// Update loading progress
export function updateLoadingProgress(progress) {
const progressBar = document.getElementById('loadProgress');
if (progressBar) {
progressBar.style.width = `${progress}%`;
}
}
// Display video info
export async function displayVideoInfo(file) {
try {
const metadata = await getVideoMetadata(file);
// Update video name
const nameEl = document.getElementById('videoName');
if (nameEl) nameEl.textContent = file.name;
// Update duration
const durationEl = document.getElementById('videoDuration');
if (durationEl) durationEl.textContent = formatTime(metadata.duration);
// Update resolution
const resolutionEl = document.getElementById('videoResolution');
if (resolutionEl) {
resolutionEl.textContent = `${metadata.width}x${metadata.height}`;
}
// Update file size
const sizeEl = document.getElementById('videoSize');
if (sizeEl) sizeEl.textContent = formatFileSize(file.size);
// Update video preview
const videoPreview = document.getElementById('videoPreview');
if (videoPreview) {
videoPreview.src = URL.createObjectURL(file);
}
return metadata;
} catch (error) {
console.error('Failed to get video info:', error);
throw error;
}
}
// Show/hide modal
export function showModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('hidden');
}
}
export function hideModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('hidden');
}
}
// Update progress modal
export function updateProgressModal(progress) {
const progressText = document.getElementById('progressText');
const progressCircle = document.getElementById('progressCircle');
if (progressText) {
progressText.textContent = `${progress}%`;
}
if (progressCircle) {
const circumference = 282.7;
const offset = circumference - (progress / 100) * circumference;
progressCircle.style.strokeDashoffset = offset;
}
}
// Show success message
export function showSuccessModal(filename, fileSize) {
const infoEl = document.getElementById('outputFileInfo');
if (infoEl) {
infoEl.textContent = `${filename} (${formatFileSize(fileSize)})`;
}
hideModal('progressModal');
showModal('successModal');
}
// Show error message
export function showErrorModal(message) {
const messageEl = document.getElementById('errorMessage');
if (messageEl) {
messageEl.textContent = message;
}
hideModal('progressModal');
showModal('errorModal');
}
Step 10: Main Application
js/app.js:
import { loadFFmpeg, isFFmpegLoaded } from './ffmpeg-loader.js';
import { convertVideo } from './video-processor.js';
import {
validateVideoFile,
downloadBlob,
generateOutputFilename
} from './utils.js';
import {
showSection,
updateFFmpegStatus,
updateLoadingProgress,
displayVideoInfo,
showModal,
hideModal,
updateProgressModal,
showSuccessModal,
showErrorModal
} from './ui-controller.js';
// Global state
let currentVideoFile = null;
// Initialize app
async function initApp() {
try {
showSection('loadingScreen');
updateFFmpegStatus('loading', 'Loading...');
// Load FFmpeg with progress
await loadFFmpeg((progress) => {
updateLoadingProgress(progress);
});
updateFFmpegStatus('ready', 'Ready');
showSection('uploadSection');
setupEventListeners();
} catch (error) {
console.error('Initialization error:', error);
updateFFmpegStatus('error', 'Failed to load');
alert('Failed to initialize. Please refresh the page.');
}
}
// Setup event listeners
function setupEventListeners() {
// File input
const fileInput = document.getElementById('fileInput');
const uploadArea = document.getElementById('uploadArea');
if (uploadArea && fileInput) {
uploadArea.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', handleFileSelect);
// Drag and drop
uploadArea.addEventListener('dragover', handleDragOver);
uploadArea.addEventListener('dragleave', handleDragLeave);
uploadArea.addEventListener('drop', handleDrop);
}
// Convert button
const convertBtn = document.getElementById('convertBtn');
if (convertBtn) {
convertBtn.addEventListener('click', handleConvert);
}
// New video button
const newVideoBtn = document.getElementById('newVideoBtn');
if (newVideoBtn) {
newVideoBtn.addEventListener('click', handleNewVideo);
}
// Modal close buttons
const closeSuccessBtn = document.getElementById('closeSuccessBtn');
const closeErrorBtn = document.getElementById('closeErrorBtn');
if (closeSuccessBtn) {
closeSuccessBtn.addEventListener('click', () => hideModal('successModal'));
}
if (closeErrorBtn) {
closeErrorBtn.addEventListener('click', () => hideModal('errorModal'));
}
}
// Handle file selection
async function handleFileSelect(event) {
const file = event.target.files?.[0];
if (file) {
await loadVideo(file);
}
}
// Handle drag over
function handleDragOver(event) {
event.preventDefault();
event.currentTarget.classList.add('dragover');
}
// Handle drag leave
function handleDragLeave(event) {
event.currentTarget.classList.remove('dragover');
}
// Handle drop
async function handleDrop(event) {
event.preventDefault();
event.currentTarget.classList.remove('dragover');
const file = event.dataTransfer.files?.[0];
if (file) {
await loadVideo(file);
}
}
// Load video
async function loadVideo(file) {
try {
validateVideoFile(file);
currentVideoFile = file;
await displayVideoInfo(file);
showSection('converterSection');
} catch (error) {
alert(error.message);
}
}
// Handle conversion
async function handleConvert() {
if (!currentVideoFile || !isFFmpegLoaded()) return;
const format = document.getElementById('outputFormat')?.value || 'mp4';
const quality = document.getElementById('quality')?.value || 'medium';
const resolution = document.getElementById('resolution')?.value || 'original';
try {
// Show progress modal
showModal('progressModal');
updateProgressModal(0);
// Disable buttons
setButtonsDisabled(true);
// Convert video
const outputBlob = await convertVideo(currentVideoFile, {
format,
quality,
resolution
});
// Generate filename and download
const filename = generateOutputFilename(currentVideoFile.name, format);
downloadBlob(outputBlob, filename);
// Show success
showSuccessModal(filename, outputBlob.size);
} catch (error) {
console.error('Conversion error:', error);
showErrorModal(error.message);
} finally {
setButtonsDisabled(false);
}
}
// Handle new video
function handleNewVideo() {
currentVideoFile = null;
const videoPreview = document.getElementById('videoPreview');
if (videoPreview) {
videoPreview.src = '';
}
showSection('uploadSection');
}
// Disable/enable buttons
function setButtonsDisabled(disabled) {
const buttons = [
'convertBtn',
'newVideoBtn',
'outputFormat',
'quality',
'resolution'
];
buttons.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.disabled = disabled;
}
});
}
// Start the app
initApp();
Running the Application
Local Development
-
Create project structure:
mkdir ffmpeg-video-converter cd ffmpeg-video-converter mkdir css js assets -
Add all files as shown above
-
Serve with local server:
# Using Python python -m http.server 8000 # Using Node.js npx serve # Using PHP php -S localhost:8000 -
Open browser:
http://localhost:8000
Important Notes
CORS Headers Required:
FFmpeg.wasm requires SharedArrayBuffer, which needs these headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Most local dev servers don’t set these by default. Use:
# Install serve with CORS support
npm install -g serve
# Run with CORS headers
serve -p 8000 --cors
How It Works
1. Initialization:
- FFmpeg.wasm loads on page load
- Core and WASM files downloaded from CDN
- Progress tracked and displayed
2. Video Upload:
- Drag-drop or file picker
- File validation (type and size)
- Metadata extraction
3. Conversion:
- User selects format and quality
- FFmpeg command built dynamically
- Video processed in browser
- Progress tracked in real-time
4. Download:
- Converted video created as Blob
- Automatic download triggered
- Temporary URLs cleaned up
Browser Support
Requires modern browsers with:
- WebAssembly support
- SharedArrayBuffer support
- ES6 modules support
Supported:
- Chrome 94+
- Edge 94+
- Firefox 95+
- Safari 16.4+
Customization Options
Add More Formats:
// In video-processor.js
const formats = {
mp4: { codec: 'libx264', ext: 'mp4' },
gif: { codec: 'gif', ext: 'gif' }, // Add GIF
// ... more formats
};
Add Video Filters:
// In buildConvertCommand()
command.push('-vf', 'hue=s=0'); // Grayscale
command.push('-vf', 'hflip'); // Horizontal flip
Add Audio Options:
command.push('-an'); // Remove audio
command.push('-c:a', 'copy'); // Copy audio without re-encoding
Performance Tips
- Use appropriate quality settings - High quality is slower
- Limit file size - Keep videos under 500MB
- Choose efficient formats - WebM is slower than MP4
- Close unnecessary tabs - Free up browser memory
- Use Chrome/Edge - Best WebAssembly performance
Troubleshooting
FFmpeg won’t load:
- Check console for CORS errors
- Ensure COOP/COEP headers are set
- Try different local server
Conversion fails:
- Check file format is valid
- Reduce quality setting
- Try smaller file
- Check browser console for errors
Slow conversion:
- Use lower quality preset
- Reduce output resolution
- Close other applications
- Use faster browser (Chrome)
Memory errors:
- Reduce file size
- Close other tabs
- Restart browser
- Use 64-bit browser
Production Deployment
1. Configure Server Headers:
Nginx:
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";
Apache:
Header set Cross-Origin-Opener-Policy "same-origin"
Header set Cross-Origin-Embedder-Policy "require-corp"
2. Optimize Assets:
- Minify CSS/JS files
- Enable gzip compression
- Use CDN for FFmpeg files
3. Add Analytics:
// Track conversions
gtag('event', 'conversion', {
format: format,
quality: quality
});
Conclusion
You now have a complete video converter running in the browser with vanilla JavaScript. FFmpeg.wasm provides professional video processing without servers or backend infrastructure. The modular code structure makes it easy to add features and customize the interface.
This approach works for any FFmpeg operation: trimming, filtering, watermarking, or complex video editing. All processing happens client-side, ensuring privacy and reducing server costs.
Explore FFmpeg’s extensive capabilities and build powerful video tools that run entirely in the browser!
Related Articles
How to Integrate Mozilla PDF.js in HTML: Build a PDF Viewer in Browser
Quick guide to integrating Mozilla PDF.js into your HTML application to build a functional PDF viewer directly in the browser.
Build PDFs Directly in the Browser: jsPDF vs pdf-lib vs PDF.js (Real Examples & Use Cases)
A practical comparison of jsPDF, pdf-lib, and PDF.js for browser-based PDF generation and manipulation. Learn which library fits your project with real code examples.
Fix: addEventListener is not a function error in JavaScript
Learn how to fix the 'addEventListener is not a function' error in JavaScript applications. This comprehensive guide covers DOM manipulation, Node.js, and browser compatibility.