Introduction
Adding subtitles to videos is essential for accessibility, multilingual content, and social media engagement. Traditional subtitle editing tools often require desktop software or server-side processing, which complicates the workflow and raises privacy concerns.
In this article, we'll explore how we built a pure client-side video subtitle editor that runs entirely in the browser. By leveraging FFmpeg compiled to WebAssembly (FFmpeg.wasm), we enable users to burn subtitles directly into videos with customizable styling—all without uploading files to a server.
Why Browser-Based Subtitle Editing?
1. Complete Privacy
Your video content never leaves your device. Perfect for confidential interviews, proprietary content, or personal videos.
2. Instant Preview & Processing
See your subtitle changes in real-time and process videos immediately without waiting for server queues.
3. No Software Installation
Works in any modern browser. No need to download and install complex video editing software.
4. Cross-Platform
Works on Windows, macOS, Linux, and even mobile devices (with limitations).
5. Zero Infrastructure Costs
Pure client-side processing eliminates server expenses for video encoding.
Architecture Overview
Core Components
1. The Main Component: VideoAddSubtitlesClient.tsx
This comprehensive component handles the entire subtitle editing and burning workflow:
"use client";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
import {
Upload,
Download,
Loader2,
Settings2,
Type,
Plus,
Trash2,
Play,
Clock
} from "lucide-react";
import { loadFFmpeg } from "@/utils/ffmpegLoader";
import { useTranslations } from "@/hooks/useTranslations";
// Types
interface SubtitleEntry {
id: string;
startTime: number;
endTime: number;
text: string;
}
interface SubtitleSettings {
fontSize: number;
color: "white" | "yellow";
position: "top" | "bottom";
}
export function VideoAddSubtitlesClient({ lang }: { lang: string }) {
const t = useTranslations(lang);
// State management
const [videoFile, setVideoFile] = useState<File | null>(null);
const [subtitles, setSubtitles] = useState<SubtitleEntry[]>([]);
const [settings, setSettings] = useState<SubtitleSettings>({
fontSize: 24,
color: "white",
position: "bottom",
});
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [ffmpeg, setFfmpeg] = useState<FFmpeg | null>(null);
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [processedVideoUrl, setProcessedVideoUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load FFmpeg
useEffect(() => {
const initFFmpeg = async () => {
try {
const ffmpegInstance = await loadFFmpeg();
setFfmpeg(ffmpegInstance);
} catch (err) {
console.error("Failed to load FFmpeg:", err);
}
};
initFFmpeg();
}, []);
// Handle file upload
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setVideoFile(file);
const url = URL.createObjectURL(file);
setVideoUrl(url);
setProcessedVideoUrl(null);
}
};
// Add new subtitle entry
const addSubtitle = () => {
const newSubtitle: SubtitleEntry = {
id: Date.now().toString(),
startTime: 0,
endTime: 5,
text: "",
};
setSubtitles([...subtitles, newSubtitle]);
};
// Update subtitle
const updateSubtitle = (id: string, updates: Partial<SubtitleEntry>) => {
setSubtitles(subtitles.map((sub) =>
sub.id === id ? { ...sub, ...updates } : sub
));
};
// Remove subtitle
const removeSubtitle = (id: string) => {
setSubtitles(subtitles.filter((sub) => sub.id !== id));
};
// Generate SRT content
const generateSRT = (): string => {
return subtitles
.sort((a, b) => a.startTime - b.startTime)
.map((sub, index) => {
const start = formatTime(sub.startTime);
const end = formatTime(sub.endTime);
return `${index + 1}\n${start} --> ${end}\n${sub.text}\n`;
})
.join("\n");
};
// Format time to SRT format (HH:MM:SS,mmm)
const formatTime = (seconds: number): string => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return `${String(hrs).padStart(2, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
};
// Process video with subtitles
const processVideo = async () => {
if (!videoFile || !ffmpeg || subtitles.length === 0) return;
setIsProcessing(true);
setProgress(0);
try {
const inputName = "input" + getFileExtension(videoFile.name);
const srtName = "subtitles.srt";
const outputName = "output.mp4";
// Write video file to FFmpeg VFS
await ffmpeg.writeFile(inputName, await fetchFile(videoFile));
// Generate and write SRT file
const srtContent = generateSRT();
await ffmpeg.writeFile(srtName, new TextEncoder().encode(srtContent));
// Build FFmpeg command with subtitle filter
const alignment = settings.position === "top" ? "6" : "2";
const colorCode = settings.color === "white" ? "&H00FFFFFF" : "&H0000FFFF";
await ffmpeg.exec([
"-i", inputName,
"-vf", `subtitles=${srtName}:force_style='FontSize=${settings.fontSize},PrimaryColour=${colorCode},OutlineColour=&H00000000,Outline=1,Shadow=0,MarginV=20,Alignment=${alignment}'`,
"-c:a", "copy", // Copy audio without re-encoding
"-y", // Overwrite output
outputName
]);
// Read processed video
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: "video/mp4" });
const url = URL.createObjectURL(blob);
setProcessedVideoUrl(url);
// Cleanup
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(srtName);
await ffmpeg.deleteFile(outputName);
} catch (err) {
console.error("Processing error:", err);
} finally {
setIsProcessing(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
{/* Component UI */}
</div>
);
}
// Helper function
const getFileExtension = (filename: string): string => {
const ext = filename.slice(filename.lastIndexOf("."));
return ext || ".mp4";
};
2. SRT Format Generation
The component generates SRT (SubRip Subtitle) format, which is widely supported:
1
00:00:00,000 --> 00:00:05,000
Hello, this is the first subtitle
2
00:00:05,000 --> 00:00:10,000
And this is the second one
Format breakdown:
- Index: Sequential number (1, 2, 3...)
-
Timecode:
HH:MM:SS,mmm --> HH:MM:SS,mmm - Text: Subtitle content (can span multiple lines)
- Blank line: Separates entries
3. FFmpeg Subtitle Filter
The magic happens in FFmpeg's subtitles filter with force_style:
const filterString = `subtitles=${srtName}:force_style='` +
`FontSize=${settings.fontSize},` + // Text size in pixels
`PrimaryColour=${colorCode},` + // Text color
`OutlineColour=&H00000000,` + // Black outline
`Outline=1,` + // Outline thickness
`Shadow=0,` + // No shadow
`MarginV=20,` + // Vertical margin
`Alignment=${alignment}'`; // Position (2=bottom, 6=top)
Style Parameters:
| Parameter | Value | Description |
|---|---|---|
| FontSize | 12-48 | Text size in pixels |
| PrimaryColour | &H00FFFFFF | White text (BGR hex) |
| OutlineColour | &H00000000 | Black outline |
| Outline | 1 | Outline thickness |
| Alignment | 2, 6 | 2=bottom, 6=top |
| MarginV | 20 | Vertical margin |
The Subtitle Burning Process
Complete Data Flow
Subtitle Management Features
Dynamic Entry Management
Users can add, edit, and remove subtitle entries dynamically:
// Add subtitle
const addSubtitle = () => {
const newSubtitle: SubtitleEntry = {
id: Date.now().toString(),
startTime: 0,
endTime: 5,
text: "",
};
setSubtitles([...subtitles, newSubtitle]);
};
// Update specific field
const updateSubtitle = (id: string, updates: Partial<SubtitleEntry>) => {
setSubtitles(subtitles.map((sub) =>
sub.id === id ? { ...sub, ...updates } : sub
));
};
// Remove subtitle
const removeSubtitle = (id: string) => {
setSubtitles(subtitles.filter((sub) => sub.id !== id));
};
Time Formatting
Convert seconds to SRT timecode format:
const formatTime = (seconds: number): string => {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return `${pad(hrs)}:${pad(mins)}:${pad(secs)},${pad(ms, 3)}`;
};
const pad = (num: number, digits: number = 2): string =>
String(num).padStart(digits, "0");
Styling Customization
Font Size Control
<input
type="range"
min="12"
max="48"
value={settings.fontSize}
onChange={(e) => setSettings({
...settings,
fontSize: parseInt(e.target.value)
})}
/>
Color Selection
<div className="flex gap-2">
<button
onClick={() => setSettings({ ...settings, color: "white" })}
className={settings.color === "white" ? "active" : ""}
>
{t("white")}
</button>
<button
onClick={() => setSettings({ ...settings, color: "yellow" })}
className={settings.color === "yellow" ? "active" : ""}
>
{t("yellow")}
</button>
</div>
Position Control
<div className="flex gap-2">
<button
onClick={() => setSettings({ ...settings, position: "top" })}
className={settings.position === "top" ? "active" : ""}
>
{t("top")}
</button>
<button
onClick={() => setSettings({ ...settings, position: "bottom" })}
className={settings.position === "bottom" ? "active" : ""}
>
{t("bottom")}
</button>
</div>
FFmpeg Command Deep Dive
The Complete Command
ffmpeg -i input.mp4 \
-vf "subtitles=subtitles.srt:force_style='FontSize=24,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=1,Shadow=0,MarginV=20,Alignment=2'" \
-c:a copy \
-y \
output.mp4
Parameter Breakdown
| Flag | Value | Purpose |
|---|---|---|
-i |
input.mp4 | Input video file |
-vf |
subtitles=... | Video filter for burning subtitles |
-c:a |
copy | Copy audio codec (no re-encoding) |
-y |
Overwrite output file if exists |
Why Copy Audio?
"-c:a", "copy"
- Faster processing: No audio re-encoding
- Preserves quality: Original audio quality maintained
- Smaller output: No generational loss
- Lower CPU usage: Audio stream copied as-is
Technical Implementation Details
FFmpeg.wasm Virtual File System
FFmpeg.wasm operates on a virtual in-memory filesystem:
// Write files to VFS
await ffmpeg.writeFile("input.mp4", await fetchFile(videoFile));
await ffmpeg.writeFile("subtitles.srt", new TextEncoder().encode(srtContent));
// Read processed file
const data = await ffmpeg.readFile("output.mp4");
// Cleanup
await ffmpeg.deleteFile("input.mp4");
await ffmpeg.deleteFile("subtitles.srt");
await ffmpeg.deleteFile("output.mp4");
Color Code Format
FFmpeg uses BGR (Blue-Green-Red) hexadecimal format with alpha:
&H00FFFFFF // White: Alpha=00, Blue=FF, Green=FF, Red=FF
&H0000FFFF // Yellow: Alpha=00, Blue=FF, Green=FF, Red=00
&H00000000 // Black: Alpha=00, Blue=00, Green=00, Red=00
Format: &HAABBGGRR
-
AA: Alpha channel (00 = opaque) -
BB: Blue component -
GG: Green component -
RR: Red component
Performance Considerations
Memory Management
- Video size: Large videos consume significant memory
- Cleanup: Always delete VFS files after processing
- Blob URLs: Revoke when no longer needed
Processing Time
Factors affecting speed:
- Video resolution: Higher = slower
- Video length: Longer = slower
- Subtitle complexity: More entries = slightly slower
- Hardware: WebAssembly performance varies by device
Optimization Tips
- Preview first: Test with a short clip
- Reasonable length: Process videos under 5 minutes
- Lower resolution: Works faster on 720p vs 4K
- Chrome/Edge: Best WASM performance
Error Handling Strategy
try {
await ffmpeg.exec([...]);
} catch (err) {
if (err.message.includes("Invalid data")) {
setError(t("invalidVideoFormat"));
} else if (err.message.includes("OOM")) {
setError(t("outOfMemory"));
} else {
setError(t("processingError"));
}
console.error("FFmpeg error:", err);
} finally {
// Always cleanup
await cleanupFiles();
}
Common errors:
- Invalid video format: Unsupported codec
- Out of memory: Video too large for browser
- Subtitle timing overlap: Invalid SRT format
- Missing files: VFS not properly initialized
Browser Compatibility
| Browser | Support | Notes |
|---|---|---|
| Chrome 90+ | ✅ Full | Best performance |
| Edge 90+ | ✅ Full | Best performance |
| Firefox 89+ | ✅ Full | Good performance |
| Safari 15+ | ✅ Full | May need user gesture |
| Mobile | ⚠️ Partial | Memory limitations |
Requirements:
- WebAssembly (WASM) support
- SharedArrayBuffer (for multi-threading)
- ES2020+ JavaScript features
- Sufficient available RAM (2GB+ recommended)
Security Considerations
- Client-Side Processing: Videos never leave the browser
- CSP Headers: Ensure CDN sources are whitelisted
- XSS Prevention: Sanitize subtitle text (already handled by FFmpeg)
- Memory Limits: Browser tabs have memory constraints
- No Server Storage: Temporary files only exist in memory
Build Configuration
package.json Dependencies
{
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"lucide-react": "^0.294.0"
}
}
Next.js Configuration
// next.config.js
const nextConfig = {
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
};
return config;
},
headers: async () => [
{
source: "/:path*",
headers: [
{
key: "Cross-Origin-Embedder-Policy",
value: "require-corp",
},
{
key: "Cross-Origin-Opener-Policy",
value: "same-origin",
},
],
},
],
};
module.exports = nextConfig;
Important Headers:
Cross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin
Required for SharedArrayBuffer support in FFmpeg.wasm.
Conclusion
We've demonstrated how to build a professional video subtitle editor that runs entirely in the browser. By leveraging FFmpeg.wasm, we enable users to:
- ✏️ Create and edit subtitle entries with precise timing
- 🎨 Customize styling (font size, color, position)
- 🔥 Burn subtitles directly into video
- 🔒 Maintain privacy with client-side processing
- ⚡ Get instant results without server delays
The combination of React state management, FFmpeg.wasm processing, and SRT format support creates a powerful yet user-friendly subtitle editing experience.
Try It Yourself
Ready to add subtitles to your videos? Visit our online tool:
Our tool is completely free, requires no registration, and processes everything locally in your browser. Add professional subtitles to your videos with customizable styling!
Built with ❤️ using Next.js, FFmpeg.wasm, and modern web technologies.



Top comments (0)