ffmpeg-webcli-browser-video-editor
A browser-based video editor powered by ffmpeg.wasm for client-side video processing without server uploads
How do I install this agent skill?
npx skills add https://github.com/reason-machines/devtools-skills --skill ffmpeg-webcli-browser-video-editorIs this agent skill safe to install?
- Gen Agent Trust Hubpass
This skill provides a comprehensive guide and code implementations for performing client-side video editing using ffmpeg.wasm. It enables operations like conversion, trimming, and filtering to happen entirely within the user's browser, ensuring data remains local without needing to upload files to a server.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
ffmpeg webCLI Browser Video Editor
Skill by ara.so — Devtools Skills collection.
ffmpeg webCLI is a browser-based video editor powered by ffmpeg.wasm that processes videos entirely client-side using WebAssembly. No server uploads, no backend infrastructure — all video processing happens locally in the user's browser. It supports 30+ video operations including GIF creation, format conversion, compression, trimming, filters, effects, and more.
Installation
Option 1: Use the Live App
Access the hosted version directly:
https://tejaswigowda.com/ffmpeg-webCLI/
Option 2: Clone and Run Locally
git clone https://github.com/tejaswigowda/ffmpeg-webCLI.git
cd ffmpeg-webCLI
# Serve the files with any static server
python -m http.server 8000
# or
npx serve .
Option 3: Integrate into Your Project
The project uses ffmpeg.wasm as its core dependency. To integrate similar functionality:
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js"></script>
</head>
<body>
<input type="file" id="videoInput" accept="video/*">
<button id="processBtn">Process Video</button>
<video id="output" controls></video>
<script src="app.js"></script>
</body>
</html>
Core Architecture
ffmpeg webCLI uses:
- ffmpeg.wasm — WebAssembly port of ffmpeg for browser execution
- Web Workers — Background processing to keep UI responsive
- PWA — Progressive Web App for offline support
- Wake Lock API — Prevents screen sleep during processing
Key Components
1. Initialize ffmpeg.wasm
const { FFmpeg } = FFmpegWASM;
const { fetchFile } = FFmpegWASM;
let ffmpeg = null;
let loaded = false;
async function loadFFmpeg() {
if (loaded) return;
ffmpeg = new FFmpeg();
// Log ffmpeg output
ffmpeg.on('log', ({ message }) => {
console.log(message);
});
// Track progress
ffmpeg.on('progress', ({ progress, time }) => {
console.log(`Progress: ${Math.round(progress * 100)}%`);
updateProgressBar(progress);
});
// Load the core and wasm files
await ffmpeg.load({
coreURL: 'https://unpkg.com/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.js',
wasmURL: 'https://unpkg.com/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.wasm',
});
loaded = true;
console.log('FFmpeg loaded successfully');
}
2. Load Video File into Virtual Filesystem
async function loadVideoFile(file) {
await loadFFmpeg();
// Write file to ffmpeg's virtual filesystem
await ffmpeg.writeFile('input.mp4', await fetchFile(file));
console.log('Video loaded into virtual filesystem');
}
3. Convert Video to GIF
async function convertToGIF(inputFile, width = 480, fps = 10) {
await loadVideoFile(inputFile);
// Two-pass palette generation for best quality
// Pass 1: Generate palette
await ffmpeg.exec([
'-i', 'input.mp4',
'-vf', `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen`,
'palette.png'
]);
// Pass 2: Use palette to create GIF
await ffmpeg.exec([
'-i', 'input.mp4',
'-i', 'palette.png',
'-filter_complex', `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse`,
'output.gif'
]);
// Read the output file
const data = await ffmpeg.readFile('output.gif');
return new Blob([data.buffer], { type: 'image/gif' });
}
4. Convert Video Format
async function convertFormat(inputFile, outputFormat = 'mp4') {
await loadVideoFile(inputFile);
const outputFile = `output.${outputFormat}`;
const formatConfigs = {
mp4: ['-c:v', 'libx264', '-c:a', 'aac'],
webm: ['-c:v', 'libvpx-vp9', '-c:a', 'libopus'],
mkv: ['-c:v', 'libx264', '-c:a', 'aac'],
mov: ['-c:v', 'libx264', '-c:a', 'aac'],
avi: ['-c:v', 'libx264', '-c:a', 'aac']
};
await ffmpeg.exec([
'-i', 'input.mp4',
...formatConfigs[outputFormat],
outputFile
]);
const data = await ffmpeg.readFile(outputFile);
return new Blob([data.buffer], { type: `video/${outputFormat}` });
}
5. Compress Video with CRF
async function compressVideo(inputFile, crf = 23, preset = 'medium') {
await loadVideoFile(inputFile);
// CRF: 18 (near lossless) to 51 (maximum compression)
// Preset: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow
await ffmpeg.exec([
'-i', 'input.mp4',
'-c:v', 'libx264',
'-crf', crf.toString(),
'-preset', preset,
'-c:a', 'aac',
'output.mp4'
]);
const data = await ffmpeg.readFile('output.mp4');
return new Blob([data.buffer], { type: 'video/mp4' });
}
6. Trim Video
async function trimVideo(inputFile, startTime, endTime) {
await loadVideoFile(inputFile);
const duration = endTime - startTime;
await ffmpeg.exec([
'-ss', startTime.toString(),
'-i', 'input.mp4',
'-t', duration.toString(),
'-c', 'copy', // Stream copy for fast, lossless trim
'output.mp4'
]);
const data = await ffmpeg.readFile('output.mp4');
return new Blob([data.buffer], { type: 'video/mp4' });
}
7. Extract Audio
async function extractAudio(inputFile, format = 'mp3') {
await loadVideoFile(inputFile);
const outputFile = `output.${format}`;
const audioConfigs = {
mp3: ['-c:a', 'libmp3lame', '-q:a', '2'],
aac: ['-c:a', 'aac', '-b:a', '192k'],
wav: ['-c:a', 'pcm_s16le'],
ogg: ['-c:a', 'libvorbis', '-q:a', '5'],
flac: ['-c:a', 'flac']
};
await ffmpeg.exec([
'-i', 'input.mp4',
'-vn', // No video
...audioConfigs[format],
outputFile
]);
const data = await ffmpeg.readFile(outputFile);
return new Blob([data.buffer], { type: `audio/${format}` });
}
8. Resize Video
async function resizeVideo(inputFile, width, height = -1) {
await loadVideoFile(inputFile);
// height = -1 maintains aspect ratio
await ffmpeg.exec([
'-i', 'input.mp4',
'-vf', `scale=${width}:${height}`,
'-c:v', 'libx264',
'-crf', '23',
'-c:a', 'copy',
'output.mp4'
]);
const data = await ffmpeg.readFile('output.mp4');
return new Blob([data.buffer], { type: 'video/mp4' });
}
9. Change Video Speed
async function changeSpeed(inputFile, speedMultiplier) {
await loadVideoFile(inputFile);
const videoPTS = 1 / speedMultiplier;
// Build atempo filter chain (each atempo can only do 0.5-2.0x)
let atempoChain = '';
let remaining = speedMultiplier;
while (remaining > 2.0) {
atempoChain += 'atempo=2.0,';
remaining /= 2.0;
}
while (remaining < 0.5) {
atempoChain += 'atempo=0.5,';
remaining /= 0.5;
}
atempoChain += `atempo=${remaining.toFixed(3)}`;
await ffmpeg.exec([
'-i', 'input.mp4',
'-filter:v', `setpts=${videoPTS}*PTS`,
'-filter:a', atempoChain,
'-c:v', 'libx264',
'-crf', '23',
'output.mp4'
]);
const data = await ffmpeg.readFile('output.mp4');
return new Blob([data.buffer], { type: 'video/mp4' });
}
10. Rotate Video
async function rotateVideo(inputFile, rotation) {
await loadVideoFile(inputFile);
const rotations = {
'90cw': 'transpose=1',
'90ccw': 'transpose=2',
'180': 'transpose=1,transpose=1',
'hflip': 'hflip',
'vflip': 'vflip',
'both': 'hflip,vflip'
};
await ffmpeg.exec([
'-i', 'input.mp4',
'-vf', rotations[rotation],
'-c:a', 'copy',
'output.mp4'
]);
const data = await ffmpeg.readFile('output.mp4');
return new Blob([data.buffer], { type: 'video/mp4' });
}
11. Add Watermark/Logo
async function addWatermark(inputFile, logoFile, position = 'bottom-right', widthPercent = 15) {
await loadVideoFile(inputFile);
await ffmpeg.writeFile('logo.png', await fetchFile(logoFile));
const positions = {
'top-left': '10:10',
'top-right': 'W-w-10:10',
'bottom-left': '10:H-h-10',
'bottom-right': 'W-w-10:H-h-10',
'center': '(W-w)/2:(H-h)/2'
};
const scaleFilter = `[1:v]scale=iw*${widthPercent/100}:-1[logo]`;
const overlayFilter = `[0:v][logo]overlay=${positions[position]}`;
await ffmpeg.exec([
'-i', 'input.mp4',
'-i', 'logo.png',
'-filter_complex', `${scaleFilter};${overlayFilter}`,
'-c:a', 'copy',
'output.mp4'
]);
const data = await ffmpeg.readFile('output.mp4');
return new Blob([data.buffer], { type: 'video/mp4' });
}
12. Adjust Brightness/Contrast/Saturation
async function adjustColors(inputFile, brightness = 0, contrast = 1, saturation = 1, grayscale = false) {
await loadVideoFile(inputFile);
const sat = grayscale ? 0 : saturation;
await ffmpeg.exec([
'-i', 'input.mp4',
'-vf', `eq=brightness=${brightness}:contrast=${contrast}:saturation=${sat}`,
'-c:a', 'copy',
'output.mp4'
]);
const data = await ffmpeg.readFile('output.mp4');
return new Blob([data.buffer], { type: 'video/mp4' });
}
13. Concatenate Videos
async function concatenateVideos(inputFile1, inputFile2) {
await ffmpeg.writeFile('input1.mp4', await fetchFile(inputFile1));
await ffmpeg.writeFile('input2.mp4', await fetchFile(inputFile2));
await ffmpeg.exec([
'-i', 'input1.mp4',
'-i', 'input2.mp4',
'-filter_complex', '[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[outv][outa]',
'-map', '[outv]',
'-map', '[outa]',
'-c:v', 'libx264',
'-c:a', 'aac',
'output.mp4'
]);
const data = await ffmpeg.readFile('output.mp4');
return new Blob([data.buffer], { type: 'video/mp4' });
}
14. Extract Thumbnail
async function extractThumbnail(inputFile, timestamp = '00:00:05', format = 'jpg') {
await loadVideoFile(inputFile);
const outputFile = `thumbnail.${format}`;
await ffmpeg.exec([
'-ss', timestamp,
'-i', 'input.mp4',
'-vframes', '1',
'-q:v', '2',
outputFile
]);
const data = await ffmpeg.readFile(outputFile);
const mimeType = format === 'png' ? 'image/png' : 'image/jpeg';
return new Blob([data.buffer], { type: mimeType });
}
Complete Working Example
// complete-video-processor.js
const { FFmpeg } = FFmpegWASM;
const { fetchFile } = FFmpegWASM;
class VideoProcessor {
constructor() {
this.ffmpeg = null;
this.loaded = false;
}
async init() {
if (this.loaded) return;
this.ffmpeg = new FFmpeg();
this.ffmpeg.on('log', ({ message }) => {
console.log('[FFmpeg]:', message);
});
this.ffmpeg.on('progress', ({ progress }) => {
const percent = Math.round(progress * 100);
this.updateProgress(percent);
});
await this.ffmpeg.load({
coreURL: 'https://unpkg.com/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.js',
wasmURL: 'https://unpkg.com/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.wasm',
});
this.loaded = true;
console.log('FFmpeg loaded and ready');
}
updateProgress(percent) {
const progressBar = document.getElementById('progress');
if (progressBar) {
progressBar.style.width = `${percent}%`;
progressBar.textContent = `${percent}%`;
}
}
async loadFile(file, filename = 'input.mp4') {
await this.init();
await this.ffmpeg.writeFile(filename, await fetchFile(file));
}
async getOutputBlob(filename, mimeType) {
const data = await this.ffmpeg.readFile(filename);
return new Blob([data.buffer], { type: mimeType });
}
async convertToGIF(file, options = {}) {
const { width = 480, fps = 10 } = options;
await this.loadFile(file);
// Generate palette
await this.ffmpeg.exec([
'-i', 'input.mp4',
'-vf', `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen`,
'palette.png'
]);
// Create GIF with palette
await this.ffmpeg.exec([
'-i', 'input.mp4',
'-i', 'palette.png',
'-filter_complex', `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse`,
'output.gif'
]);
return await this.getOutputBlob('output.gif', 'image/gif');
}
async compress(file, options = {}) {
const { crf = 23, preset = 'medium' } = options;
await this.loadFile(file);
await this.ffmpeg.exec([
'-i', 'input.mp4',
'-c:v', 'libx264',
'-crf', crf.toString(),
'-preset', preset,
'-c:a', 'aac',
'output.mp4'
]);
return await this.getOutputBlob('output.mp4', 'video/mp4');
}
async trim(file, startTime, duration) {
await this.loadFile(file);
await this.ffmpeg.exec([
'-ss', startTime.toString(),
'-i', 'input.mp4',
'-t', duration.toString(),
'-c', 'copy',
'output.mp4'
]);
return await this.getOutputBlob('output.mp4', 'video/mp4');
}
downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
}
// Usage
const processor = new VideoProcessor();
document.getElementById('gifBtn').addEventListener('click', async () => {
const fileInput = document.getElementById('videoInput');
const file = fileInput.files[0];
if (!file) {
alert('Please select a video file');
return;
}
try {
const gifBlob = await processor.convertToGIF(file, {
width: 640,
fps: 15
});
processor.downloadBlob(gifBlob, 'output.gif');
} catch (error) {
console.error('Error:', error);
alert('Processing failed: ' + error.message);
}
});
HTML Interface Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Processor</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.controls {
margin: 20px 0;
}
button {
padding: 10px 20px;
margin: 5px;
cursor: pointer;
}
#progress {
width: 0%;
height: 30px;
background: #4CAF50;
text-align: center;
line-height: 30px;
color: white;
transition: width 0.3s;
}
.progress-container {
width: 100%;
background: #ddd;
margin: 20px 0;
}
</style>
</head>
<body>
<h1>Browser Video Processor</h1>
<div class="controls">
<input type="file" id="videoInput" accept="video/*">
</div>
<div class="controls">
<button id="gifBtn">Convert to GIF</button>
<button id="compressBtn">Compress Video</button>
<button id="extractAudioBtn">Extract Audio</button>
<button id="thumbnailBtn">Extract Thumbnail</button>
</div>
<div class="progress-container">
<div id="progress">0%</div>
</div>
<div id="output"></div>
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js"></script>
<script src="complete-video-processor.js"></script>
<script>
const processor = new VideoProcessor();
document.getElementById('compressBtn').addEventListener('click', async () => {
const file = document.getElementById('videoInput').files[0];
if (!file) return alert('Select a video first');
const compressed = await processor.compress(file, { crf: 28 });
processor.downloadBlob(compressed, 'compressed.mp4');
});
document.getElementById('extractAudioBtn').addEventListener('click', async () => {
const file = document.getElementById('videoInput').files[0];
if (!file) return alert('Select a video first');
await processor.loadFile(file);
await processor.ffmpeg.exec([
'-i', 'input.mp4',
'-vn',
'-c:a', 'libmp3lame',
'audio.mp3'
]);
const audio = await processor.getOutputBlob('audio.mp3', 'audio/mp3');
processor.downloadBlob(audio, 'audio.mp3');
});
document.getElementById('thumbnailBtn').addEventListener('click', async () => {
const file = document.getElementById('videoInput').files[0];
if (!file) return alert('Select a video first');
await processor.loadFile(file);
await processor.ffmpeg.exec([
'-ss', '00:00:03',
'-i', 'input.mp4',
'-vframes', '1',
'thumb.jpg'
]);
const thumb = await processor.getOutputBlob('thumb.jpg', 'image/jpeg');
processor.downloadBlob(thumb, 'thumbnail.jpg');
});
</script>
</body>
</html>
Advanced Patterns
Screen Wake Lock During Processing
async function processWithWakeLock(processingFunction) {
let wakeLock = null;
try {
if ('wakeLock' in navigator) {
wakeLock = await navigator.wakeLock.request('screen');
console.log('Wake lock activated');
}
const result = await processingFunction();
return result;
} finally {
if (wakeLock) {
await wakeLock.release();
console.log('Wake lock released');
}
}
}
// Usage
const result = await processWithWakeLock(async () => {
return await processor.compress(videoFile, { crf: 23 });
});
Web Worker for Background Processing
// worker.js
importScripts('https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js');
const { FFmpeg } = FFmpegWASM;
let ffmpeg = null;
self.onmessage = async (e) => {
const { type, data } = e.data;
if (type === 'load') {
ffmpeg = new FFmpeg();
await ffmpeg.load();
self.postMessage({ type: 'loaded' });
}
if (type === 'process') {
const { file, operation, options } = data;
await ffmpeg.writeFile('input.mp4', file);
// Execute operation based on type
if (operation === 'compress') {
await ffmpeg.exec([
'-i', 'input.mp4',
'-c:v', 'libx264',
'-crf', options.crf.toString(),
'output.mp4'
]);
}
const output = await ffmpeg.readFile('output.mp4');
self.postMessage({ type: 'complete', data: output });
}
};
// main.js
const worker = new Worker('worker.js');
worker.onmessage = (e) => {
const { type, data } = e.data;
if (type === 'loaded') {
console.log('Worker ready');
}
if (type === 'complete') {
const blob = new Blob([data.buffer], { type: 'video/mp4' });
downloadBlob(blob, 'output.mp4');
}
};
worker.postMessage({ type: 'load' });
Estimate Output Size
async function estimateOutputSize(inputFile, crf) {
const inputSize = inputFile.size;
// Rough estimation based on CRF
// CRF 18 = ~1.0x, CRF 23 = ~0.5x, CRF 28 = ~0.25x, CRF 51 = ~0.05x
const sizeMultipliers = {
18: 1.0,
23: 0.5,
28: 0.25,
33: 0.15,
38: 0.10,
51: 0.05
};
// Interpolate
let multiplier = 0.5;
const keys = Object.keys(sizeMultipliers).map(Number).sort((a, b) => a - b);
for (let i = 0; i < keys.length - 1; i++) {
if (crf >= keys[i] && crf <= keys[i + 1]) {
const range = keys[i + 1] - keys[i];
const progress = (crf - keys[i]) / range;
multiplier = sizeMultipliers[keys[i]] +
(sizeMultipliers[keys[i + 1]] - sizeMultipliers[keys[i]]) * progress;
break;
}
}
return Math.round(inputSize * multiplier);
}
// Usage
const estimated = estimateOutputSize(videoFile, 28);
console.log(`Estimated output: ${(estimated / 1024 / 1024).toFixed(2)} MB`);
Troubleshooting
FFmpeg Not Loading
async function loadWithRetry(maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
await ffmpeg.load({
coreURL: 'https://unpkg.com/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.js',
wasmURL: 'https://unpkg.com/@ffmpeg/core@0.12.4/dist/umd/ffmpeg-core.wasm',
});
return true;
} catch (error) {
console.error(`Load attempt ${i + 1} failed:`, error);
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
Memory Issues with Large Files
// Process large files in chunks or with lower quality settings
async function processLargeFile(file) {
const MAX_SIZE = 500 * 1024 * 1024; // 500 MB
if (file.size > MAX_SIZE) {
console.warn('Large file detected, using aggressive compression');
return await processor.compress(file, {
crf: 28,
preset: 'faster' // Faster preset uses less memory
});
}
return await processor.compress(file, { crf: 23 });
}
Cross-Origin Headers for SharedArrayBuffer
If using multi-threading (advanced ffmpeg.wasm feature), serve with:
// server.js (Express example)
app.use((req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
next();
});
Clean Up Virtual Filesystem
async function cleanupFiles() {
try {
await ffmpeg.deleteFile('input.mp4');
await ffmpeg.deleteFile('output.mp4');
await ffmpeg.deleteFile('palette.png');
console.log('Cleanup complete');
} catch (error) {
console.warn('Cleanup error:', error);
}
}
// Call after each operation
await processor.compress(file);
await cleanupFiles();
Configuration
Set Custom FFmpeg Arguments
async function rawCommand(inputFile, args, outputExt) {
await processor.loadFile(inputFile);
const outputFile = `output.${outputExt}`;
// User provides args array: ['-vf', 'scale=1280:-1', '-c:v', 'libx264']
await processor.ffmpeg.exec([
'-i', 'input.mp4',
...args,
outputFile
]);
return await processor.getOutputBlob(outputFile, `video/${outputExt}`);
}
// Example: Apply custom filter
const result = await rawCommand(videoFile, [
'-vf', 'eq=brightness=0.1:contrast=1.2',
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/reason-machines/devtools-skills/ffmpeg-webcli-browser-video-editor">View ffmpeg-webcli-browser-video-editor on skillZs</a>