DEV Community

Cover image for Client-Side Video Transcoding with FFmpeg.wasm — No Server Required
Northbeam Technologies
Northbeam Technologies

Posted on • Originally published at northbeam-tech.com

Client-Side Video Transcoding with FFmpeg.wasm — No Server Required

The problem

The Kolel Creator Dashboard lets educators and rabbis upload video lessons directly from their browser. Creators record on phones, tablets, and cameras — producing files in every format imaginable: .mov, .webm, .avi, .mkv.

Our Google Cloud Video Transcoder pipeline expects MP4 input. The original solution was a dedicated pre-upload server that re-encoded every file before it hit cloud storage. One more service to deploy, monitor, and pay for.

We needed a way to normalize the format before the file ever left the creator's machine.


The solution: FFmpeg running inside the browser

We integrated @ffmpeg/ffmpeg into the Vue 2 upload component. When a creator picks a non-MP4 file, the browser:

  1. Downloads the FFmpeg WASM core (~30 MB, cached after first load)
  2. Transcodes the file to MP4 inside a Web Worker
  3. Uploads the resulting blob directly to a GCS Signed URL

The Rails API and the downstream transcoding pipeline never see anything other than a clean MP4.


The core transcoding function

import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';

async convertToMp4(file) {
  const supportedTypes = ['video/mp4', 'video/mov', 'video/m4v'];
  if (supportedTypes.includes(file.type)) {
    return; // already compatible — skip transcoding
  }

  const ffmpeg = createFFmpeg({
    log: true,
    corePath: 'https://unpkg.com/@ffmpeg/core@0.10.0/dist/ffmpeg-core.js',
  });

  if (!ffmpeg.isLoaded()) {
    await ffmpeg.load();
  }

  const fileName = file.name;
  const outputFileName = 'output.mp4';

  ffmpeg.FS('writeFile', fileName, await fetchFile(file));
  await ffmpeg.run('-i', fileName, outputFileName);

  const data = ffmpeg.FS('readFile', outputFileName);
  this.selectedFile = new Blob([data.buffer], { type: 'video/mp4' });
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting:

  • We pin a specific CDN version of @ffmpeg/core so the WASM binary is cached in the browser after the first upload session. Repeat visitors pay zero load cost.
  • We bail early for common formats (mp4, mov, m4v). Running FFmpeg.wasm on a file that's already compatible wastes time and memory.

Uploading without exposing credentials

The browser never holds GCS credentials. The Rails API generates a short-lived Signed URL for the specific file path, then the browser PUTs directly to GCS:

async uploadFileToSignedUrl(signedUrl, selectedFile) {
  await axios.put(signedUrl, selectedFile, {
    headers: { 'Content-Type': selectedFile.type },
  });
}

async prepareVideoUpload() {
  this.loading = true;
  await this.getSignedUrl();
  await this.uploadFileToSignedUrl(this.signedUrl, this.selectedFile);

  if (this.pickedThumbnail) {
    await this.getImageSignedUrl();
    await this.uploadFileToSignedUrl(this.thumbnailSignedUrl, this.pickedThumbnail);
  }

  this.addVideo(); // notify Rails API to create the video record
}
Enter fullscreen mode Exit fullscreen mode

Results

Before After
Pre-upload transcoding microservice Removed entirely
Format errors reaching the pipeline Zero
GCS credentials in the browser Never
Upload experience for MP4/MOV users Unchanged

Key takeaways

  • FFmpeg.wasm is production-ready for this use case. The WASM core is large, but browser caching makes repeat sessions fast.
  • Signed URLs are the right pattern for direct browser-to-cloud uploads. No credentials ever leave your server.
  • Always allow-list known-good formats before invoking WASM — it saves real time for the majority of your users.

Stack

Vue 2 · @ffmpeg/ffmpeg 0.10 · @ffmpeg/core (WebAssembly) · Google Cloud Storage · GCS Signed URLs · Google Cloud Video Transcoder · Rails 7 · Axios


Originally published on the Northbeam Technologies blog.

Top comments (0)