DEV Community

monkeymore studio
monkeymore studio

Posted on

Shrink Your Videos Without Sending Them Anywhere

Ever tried to email a video and watched your mail client laugh at you? Or upload a clip to a messaging app only to see it compressed into a pixelated mess? Video files get big fast — a minute of 1080p footage can easily eat up 100MB.

Most online compressors make you upload the entire file first. That is slow, eats your bandwidth, and who knows what happens to your data on the other side. We wanted something better: a tool that crunches video files down to size while keeping them on your machine the whole time.

So we built a browser-based video compressor using FFmpeg.wasm. Drop a 500MB video in, pick your settings, and download a smaller version. No upload. No queue. No server snooping on your footage.

Why Compress Video in the Browser?

If you are going to reduce a file's size, the last thing you want is to add a 200MB upload to the process. Here is why client-side compression makes sense:

  • Skip the upload entirely: Your video stays on your disk. We never see it.
  • No file size gatekeeping: Most online tools cap you at 100MB or force a paid plan. We handle files up to 2GB because the limit is your device's memory, not our server budget.
  • You control the quality: Many services use mystery settings that turn your crisp video into soup. Here you pick exactly how aggressive the compression is.
  • No waiting in line: Server-side tools queue your job behind everyone else's. Browser compression starts immediately.
  • Zero infrastructure cost for us: Your CPU does the work, so we don't need a render farm to stay afloat.

The downside? Very large or very long videos can strain your RAM. But for typical clips — a few minutes at 1080p or less — it works great.

The Full Flow

Here is what happens from drag-and-drop to download:

The Data Model

We keep the state in a single VideoFile object that tracks everything from the original upload to the compressed result:

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

The compressedSize field is what lets us show the satisfying "You saved X%" number at the end.

Three Compression Presets (Plus a Custom Mode)

Not everyone knows what a CRF value is, so we offer three one-click presets:

const COMPRESSION_PRESETS = [
  { 
    value: "high", 
    label: "High Quality", 
    videoBitrate: "2M", 
    audioBitrate: "128k",
    crf: "23",
    scale: -1 
  },
  { 
    value: "medium", 
    label: "Medium Quality", 
    videoBitrate: "1M", 
    audioBitrate: "96k",
    crf: "28",
    scale: -2 
  },
  { 
    value: "low", 
    label: "Low Quality", 
    videoBitrate: "500k", 
    audioBitrate: "64k",
    crf: "35",
    scale: -2 
  },
];
Enter fullscreen mode Exit fullscreen mode
  • High Quality (crf 23): Barely noticeable quality loss. Good for archiving or sharing where fidelity matters.
  • Medium Quality (crf 28): The sweet spot. Significantly smaller files with acceptable quality. This is our default.
  • Low Quality (crf 35): Aggressive compression. Great for drafts, previews, or situations where file size is the only thing that matters.

Each preset also sets a maximum bitrate cap and an audio bitrate, so you don't end up with a tiny video and a bloated audio track.

For power users, there is a custom bitrate mode where you can type exactly what you want:

const [customBitrate, setCustomBitrate] = useState<string>("");
const [useCustomSettings, setUseCustomSettings] = useState(false);
Enter fullscreen mode Exit fullscreen mode

Enter 800k for 800 kbps, 2M for 2 Mbps, whatever fits your needs.

Resizing While We Compress

Sometimes you don't just want a smaller file — you want a smaller resolution. We offer five options:

const RESOLUTION_OPTIONS = [
  { value: "original", label: "Original", scale: -1 },
  { value: "1080p", label: "1080p", scale: 1080 },
  { value: "720p", label: "720p", scale: 720 },
  { value: "480p", label: "480p", scale: 480 },
  { value: "360p", label: "360p", scale: 360 },
];
Enter fullscreen mode Exit fullscreen mode

When you pick anything other than "Original," we add a scale filter to the FFmpeg command. The -2 in scale=-2:720 tells FFmpeg to scale the height to 720px and automatically calculate the width while keeping the aspect ratio — and ensuring it is divisible by 2, which H.264 requires.

Building the FFmpeg Command

The actual compression logic assembles the command piece by piece based on user choices:

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

  setIsCompressing(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_compressed.mp4`;

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

    const ffmpegArgs = ["-i", inputName];
    const preset = COMPRESSION_PRESETS.find(p => p.value === compressionPreset);
    const resolution = RESOLUTION_OPTIONS.find(r => r.value === targetResolution);

    ffmpegArgs.push("-c:v", "libx264");

    if (resolution && resolution.scale > 0) {
      ffmpegArgs.push("-vf", `scale=-2:${resolution.scale}`);
    }

    if (useCustomSettings && customBitrate) {
      ffmpegArgs.push("-b:v", customBitrate);
      ffmpegArgs.push("-maxrate", customBitrate);
      ffmpegArgs.push("-bufsize", `${parseInt(customBitrate) * 2}k`);
    } else if (preset) {
      ffmpegArgs.push("-crf", preset.crf);
      ffmpegArgs.push("-preset", "medium");
      ffmpegArgs.push("-maxrate", preset.videoBitrate);
      ffmpegArgs.push("-bufsize", `${parseInt(preset.videoBitrate) * 2}k`);
    }

    ffmpegArgs.push("-c:a", "aac");
    if (preset) {
      ffmpegArgs.push("-b:a", preset.audioBitrate);
    }

    ffmpegArgs.push("-movflags", "+faststart");
    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 blob = new Blob([buffer], { type: "video/mp4" });
    const compressedUrl = URL.createObjectURL(blob);
    const baseName = selectedFile.file.name.replace(/\.[^/.]+$/, "");

    setSelectedFile(prev => prev ? {
      ...prev,
      compressedUrl,
      compressedFileName: `${baseName}_compressed.mp4`,
      compressedSize: blob.size,
      processing: false,
      progress: 100,
    } : null);

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

Let us break down what those flags actually do:

  • -c:v libx264: The gold standard for video encoding. H.264 works everywhere.
  • -crf 28: Constant Rate Factor — lower means better quality but bigger files. 28 is our default sweet spot.
  • -preset medium: A balance between encoding speed and compression efficiency.
  • -maxrate 1M + -bufsize 2M: Caps the bitrate so the file does not unexpectedly balloon. The bufsize is double the maxrate for smooth rate control.
  • -c:a aac -b:a 96k: Re-encodes audio to AAC at 96 kbps. Small but decent quality.
  • -movflags +faststart: Reorganizes the MP4 so it starts playing before the entire file downloads. Essential for web playback.

The Quality-Size Trade-Off in Numbers

The savings vary wildly depending on your source material, but here is a rough idea:

Source Preset Typical Savings
1080p screen recording Medium 60-80%
4K camera footage High 30-50%
Already-compressed MP4 Low 10-30%
High-motion gameplay Medium 40-60%

Screen recordings compress beautifully because they have lots of static areas. Already-compressed videos do not shrink as much because there is less redundancy to exploit.

Showing the Results

After compression, we show a before-and-after card:

const calculateSavings = () => {
  if (!selectedFile?.compressedSize) return 0;
  const originalSize = selectedFile.file.size;
  const compressedSize = selectedFile.compressedSize;
  const savings = ((originalSize - compressedSize) / originalSize) * 100;
  return Math.round(savings);
};
Enter fullscreen mode Exit fullscreen mode

This displays:

  • Original file size
  • Compressed file size
  • Percentage saved

It is a small touch, but seeing "Space Saved: 67%" feels surprisingly good.

Loading FFmpeg on Demand

FFmpeg.wasm is not small. We load it lazily so the page stays snappy:

// 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

Dynamic import keeps the module out of the initial bundle. Blob URLs sidestep CORS issues. And caching the instance means subsequent compressions do not pay the startup cost again.

Memory Management

Object URLs and virtual filesystem entries both leak if you forget them. We clean up on reset:

const reset = useCallback(() => {
  if (selectedFile) {
    URL.revokeObjectURL(selectedFile.previewUrl);
    if (selectedFile.compressedUrl) URL.revokeObjectURL(selectedFile.compressedUrl);
  }
  setSelectedFile(null);
  setError(null);
  setCompressionPreset("medium");
  setTargetResolution("original");
  setCustomBitrate("");
  setUseCustomSettings(false);
}, [selectedFile]);
Enter fullscreen mode Exit fullscreen mode

And after each compression:

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

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

What We Learned

A few things caught us off guard while building this:

  • CRF is not a bitrate: Users often expect CRF 23 to produce the same file size every time. It does not — it targets a quality level, so a static screen recording at CRF 28 might be 5MB while a chaotic action scene at the same CRF could be 50MB. That is why we pair CRF with -maxrate as a safety cap.
  • -movflags +faststart is essential: Without it, the generated MP4 has to fully download before playback starts. With it, playback begins almost instantly. For a tool that generates videos people will likely watch in their browser, this matters a lot.
  • MKV support is surprisingly useful: A lot of screen recorders and gaming clips output MKV by default. Supporting it means users do not have to convert first.
  • Custom bitrate is a power-user trap: Give people a text box and someone will type 999999M. We do not aggressively validate (FFmpeg will just error out), but we probably should add a sanity check eventually.

Try It Out

Got a video that is too fat for Discord, email, or your phone's storage? You can shrink it right now. No account, no upload, no mysterious quality settings.

👉 Video Compressor

Drag your video in, pick a preset or dial in your own settings, and download the compressed version. Everything happens on your machine — your video never leaves your browser.

Top comments (0)