DEV Community

monkeymore studio
monkeymore studio

Posted on

Convert Videos to Multiple Formats Without Leaving Your Browser

Need an MP4 to work in Premiere? A WebM for your website? An AVI because some ancient tool you are stuck with only accepts that? Format conversion is one of those things that sounds simple until you actually try to do it.

Most online converters follow the same tired playbook: upload your video, wait in a queue, download the result, and hope they did not watermark it or sell your data. We got tired of that dance. So we built a converter that runs entirely in your browser. Your video never leaves your machine, and you get to pick exactly what comes out the other side.

Why Bother Doing This Locally?

Uploading a video just to change its file extension feels ridiculous. Here is why keeping it in the browser actually matters:

  • No upload bottleneck: A 400MB video can take 20 minutes to upload on a slow connection. Skip that entirely.
  • Privacy by default: Your footage stays on your disk. We do not see it, store it, or process it on our end.
  • No weird limits: 500MB max file size because that is what browsers can reasonably handle, not because we want you to upgrade.
  • Instant start: No queue, no "estimated wait time," no refreshing the page to check if it is done.
  • Actually free: You are using your own CPU, so there is no server bill to pass on to you.

The trade-off is that very long or very high-resolution videos can push your browser's memory limits. But for typical clips — a few minutes at 1080p — it works without breaking a sweat.

How It All Works

Here is the full journey from dropping a file to downloading the converted version:

The Data Model

We keep everything in a single VideoFile object:

interface VideoFile {
  id: string;
  file: File;
  previewUrl: string;
  convertedUrl?: string;
  convertedFileName?: string;
  error?: string;
  processing?: boolean;
  progress?: number;
  videoWidth?: number;
  videoHeight?: number;
  duration?: number;
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy — just enough to track the original upload, the conversion progress, and the final result.

Pick Your Poison: Five Output Formats

Different formats exist for different reasons. We support the ones people actually need:

const OUTPUT_FORMATS = [
  { value: "mp4", label: "MP4", extension: "mp4", mimeType: "video/mp4" },
  { value: "webm", label: "WebM", extension: "webm", mimeType: "video/webm" },
  { value: "mov", label: "MOV", extension: "mov", mimeType: "video/quicktime" },
  { value: "avi", label: "AVI", extension: "avi", mimeType: "video/x-msvideo" },
  { value: "mkv", label: "MKV", extension: "mkv", mimeType: "video/x-matroska" },
];
Enter fullscreen mode Exit fullscreen mode

Each format gets a tailored encoding strategy because one-size-fits-all usually means one-size-fits-none.

MP4, MOV, and MKV: The H.264 Family

These three formats all use the same underlying codec stack because they are the modern standard:

case "mp4":
case "mov":
case "mkv":
  ffmpegArgs.push(
    "-c:v", "libx264",
    "-preset", "ultrafast",
    "-crf", "28",
    "-c:a", "aac",
    "-b:a", "128k",
    "-movflags", "+faststart"
  );
  break;
Enter fullscreen mode Exit fullscreen mode
  • libx264: The most compatible video encoder on the planet. Works everywhere.
  • -preset ultrafast: We prioritize speed over compression efficiency. Since this is a format converter, not a compressor, users care more about getting their file quickly than squeezing every last byte.
  • -crf 28: A reasonable quality target. Good enough for most use cases without bloating the file.
  • -movflags +faststart: Reorganizes the MP4/MOV so playback can start before the entire file is downloaded. Essential if someone wants to preview the result in their browser.

WebM: The Web-Native Choice

WebM is fantastic for websites because it streams well and has great browser support. But it needs different codecs:

case "webm":
  ffmpegArgs.push(
    "-c:v", "libvpx",
    "-b:v", "1M",
    "-c:a", "libvorbis",
    "-q:a", "4"
  );
  break;
Enter fullscreen mode Exit fullscreen mode
  • libvpx: VP8 video encoding. Widely supported and royalty-free.
  • libvorbis: The audio codec that traditionally pairs with VP8 in WebM.
  • -b:v 1M: A fixed video bitrate of 1 Mbps. WebM is often used for web delivery, so a predictable bitrate helps with bandwidth planning.

AVI: The Legacy Fallback

AVI is old, clunky, and still somehow required by a surprising number of tools:

case "avi":
  ffmpegArgs.push(
    "-c:v", "mpeg4",
    "-b:v", "800k",
    "-c:a", "aac",
    "-b:a", "128k"
  );
  break;
Enter fullscreen mode Exit fullscreen mode
  • mpeg4: Simple MPEG-4 Part 2 video. Widely supported by AVI players.
  • aac: Modern audio codec that most AVI parsers can handle these days.

The Core Conversion Logic

Here is the full convertVideo function that ties everything together:

const convertVideo = async () => {
  if (!selectedFile || !ffmpegRef.current) return;

  setIsConverting(true);
  setSelectedFile(prev => prev ? { ...prev, processing: true, progress: 0 } : null);

  try {
    const ffmpeg = ffmpegRef.current;
    const inputExt = selectedFile.file.name.split('.').pop() || 'mp4';
    const inputName = `input.${inputExt}`;
    const outputName = `output.${outputFormat}`;

    const fileArrayBuffer = await selectedFile.file.arrayBuffer();
    await ffmpeg.writeFile(inputName, new Uint8Array(fileArrayBuffer));

    const ffmpegArgs = ["-i", inputName];

    switch (outputFormat) {
      case "mp4":
      case "mov":
      case "mkv":
        ffmpegArgs.push(
          "-c:v", "libx264",
          "-preset", "ultrafast",
          "-crf", "28",
          "-c:a", "aac",
          "-b:a", "128k",
          "-movflags", "+faststart"
        );
        break;
      case "webm":
        ffmpegArgs.push(
          "-c:v", "libvpx",
          "-b:v", "1M",
          "-c:a", "libvorbis",
          "-q:a", "4"
        );
        break;
      case "avi":
        ffmpegArgs.push(
          "-c:v", "mpeg4",
          "-b:v", "800k",
          "-c:a", "aac",
          "-b:a", "128k"
        );
        break;
      default:
        ffmpegArgs.push("-c", "copy");
    }

    ffmpegArgs.push("-y", outputName);
    await ffmpeg.exec(ffmpegArgs);

    const data = await ffmpeg.readFile(outputName);
    const uint8Data = data instanceof Uint8Array ? data : new Uint8Array();
    const buffer = new ArrayBuffer(uint8Data.length);
    new Uint8Array(buffer).set(uint8Data);

    const outputFormatInfo = OUTPUT_FORMATS.find(f => f.value === outputFormat);
    const blob = new Blob([buffer], { type: outputFormatInfo?.mimeType || "video/mp4" });
    const convertedUrl = URL.createObjectURL(blob);
    const baseName = selectedFile.file.name.replace(/\.[^/.]+$/, "");

    setSelectedFile(prev => prev ? {
      ...prev,
      convertedUrl,
      convertedFileName: `${baseName}.${outputFormatInfo?.extension || outputFormat}`,
      processing: false,
      progress: 100,
    } : null);

    await ffmpeg.deleteFile(inputName);
    await ffmpeg.deleteFile(outputName);
  } catch (err) {
    setError("Failed to convert video");
    setSelectedFile(prev => prev ? { ...prev, error: "Conversion failed", processing: false } : null);
  } finally {
    setIsConverting(false);
  }
};
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • We preserve the original file extension for the input name so FFmpeg can infer the container format correctly.
  • The output Blob gets the correct MIME type so browsers handle it properly when downloading or previewing.
  • We always clean up the virtual filesystem after conversion. Those in-memory files add up fast.

Why Not Just Use -c copy?

You might wonder why we do not just use stream copying (-c copy) for all formats. After all, if you are converting MP4 to MOV, the underlying H.264 video and AAC audio are already compatible.

The answer is simple: container compatibility. MP4 and MOV are very similar, but AVI and WebM have strict requirements about which codecs they support. An H.264 stream copied directly into an AVI container might not play in older players. A WebM container flat-out rejects anything that is not VP8/VP9 or Vorbis/Opus.

So we re-encode to the target format's "native" codecs. It takes a bit longer than a straight copy, but the result actually works wherever you take it.

Loading FFmpeg Without Blocking the Page

FFmpeg.wasm is heavy — several megabytes of JavaScript and WebAssembly. We load it on demand:

// utils/ffmpegLoader.ts
import { fetchFile, toBlobURL } from "@ffmpeg/util";

let ffmpeg: any = null;
let fetchFileFn: any = null;

export async function loadFFmpeg() {
  if (ffmpeg) return { ffmpeg, fetchFile: fetchFileFn };

  const { FFmpeg } = await import("@ffmpeg/ffmpeg");

  ffmpeg = new FFmpeg();
  fetchFileFn = fetchFile;

  const baseURL = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.6/dist/umd";

  await ffmpeg.load({
    coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
    wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm"),
  }, {
    corePath: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
  });

  return { ffmpeg, fetchFile };
}
Enter fullscreen mode Exit fullscreen mode

The dynamic import keeps the initial bundle lean. toBlobURL turns CDN resources into local blob URLs, avoiding CORS headaches. And caching the instance means you do not pay the startup cost twice if you convert multiple videos.

Progress Tracking

Nobody likes a frozen UI. FFmpeg.wasm emits progress events:

ffmpeg.on("progress", ({ progress }: { progress: number }) => {
  setSelectedFile(prev => prev ? { ...prev, progress: Math.round(progress * 100) } : null);
});
Enter fullscreen mode Exit fullscreen mode

This drives a progress bar that actually moves, which makes the wait feel significantly shorter.

Cleanup

Object URLs and virtual filesystem entries both leak memory if you forget them:

const reset = useCallback(() => {
  if (selectedFile) {
    URL.revokeObjectURL(selectedFile.previewUrl);
    if (selectedFile.convertedUrl) URL.revokeObjectURL(selectedFile.convertedUrl);
  }
  setSelectedFile(null);
  setError(null);
}, [selectedFile]);
Enter fullscreen mode Exit fullscreen mode

And after each conversion:

await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
Enter fullscreen mode Exit fullscreen mode

Without this, repeated conversions would eventually hit the browser's memory ceiling.

What We Learned

Building this taught us a few things:

  • Container formats are picky: We initially tried -c copy for everything. It worked for MP4-to-MOV but spectacularly failed for MP4-to-AVI and MP4-to-WebM. Each container has its own codec whitelist, and respecting that is non-negotiable.
  • -preset ultrafast is the right call for converters: When people convert formats, they usually want speed. They are not trying to win a compression contest. Ultrafast trades a slightly larger file size for dramatically faster encoding.
  • MIME types matter for Blob URLs: If you create a Blob with the wrong MIME type, the browser might refuse to play or download it correctly. We map each output format to its proper type so the resulting file behaves normally.
  • WebM with libvpx is slow: VP8 encoding is noticeably slower than H.264. We warn users implicitly through the progress bar — WebM conversions just take longer, and that is the cost of using a royalty-free codec.

Give It a Spin

Got a video in the wrong format? You can convert it right now. No upload, no account, no waiting.

👉 Video Converter

Just drag your video in, pick the output format, and download the result. MP4, WebM, MOV, AVI, MKV — all processed locally in your browser.

Top comments (0)