DEV Community

monkeymore studio
monkeymore studio

Posted on

Adding Subtitles to Videos in the Browser: A Pure Client-Side Solution with FFmpeg.wasm

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";
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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));
};
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Styling Customization

Font Size Control

<input
  type="range"
  min="12"
  max="48"
  value={settings.fontSize}
  onChange={(e) => setSettings({
    ...settings,
    fontSize: parseInt(e.target.value)
  })}
/>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
  • 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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Preview first: Test with a short clip
  2. Reasonable length: Process videos under 5 minutes
  3. Lower resolution: Works faster on 720p vs 4K
  4. 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();
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Client-Side Processing: Videos never leave the browser
  2. CSP Headers: Ensure CDN sources are whitelisted
  3. XSS Prevention: Sanitize subtitle text (already handled by FFmpeg)
  4. Memory Limits: Browser tabs have memory constraints
  5. 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Important Headers:

  • Cross-Origin-Embedder-Policy: require-corp
  • Cross-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:

👉 Add Subtitles to Video

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)