DEV Community

TateLyman
TateLyman

Posted on

How I Built a Client-Side Video to GIF Converter with Zero Dependencies

I spent a weekend building a video-to-GIF converter that runs entirely in the browser. No server, no uploads, no ffmpeg binary — just raw JavaScript turning video frames into an animated GIF. Here's how it actually works under the hood.

Why Bother?

Most online GIF makers upload your video to some server, process it, and send back the result. That's fine until you realize:

  • Your video sits on someone else's machine
  • It's slow because you're waiting on network round-trips
  • File size limits are usually tiny
  • It breaks when the service goes down

I wanted something that processes everything locally. The video never leaves your browser tab.

Extracting Frames from Video

The first step is pulling individual frames out of a video file. The <video> element + <canvas> combo makes this surprisingly doable:

async function extractFrames(videoFile, fps = 10) {
  const video = document.createElement('video');
  video.muted = true;
  video.src = URL.createObjectURL(videoFile);

  await new Promise(resolve => {
    video.onloadedmetadata = resolve;
  });

  const canvas = document.createElement('canvas');
  canvas.width = video.videoWidth;
  canvas.height = video.videoHeight;
  const ctx = canvas.getContext('2d');

  const frames = [];
  const interval = 1 / fps;

  for (let time = 0; time < video.duration; time += interval) {
    video.currentTime = time;
    await new Promise(resolve => {
      video.onseeked = resolve;
    });
    ctx.drawImage(video, 0, 0);
    frames.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
  }

  URL.revokeObjectURL(video.src);
  return frames;
}
Enter fullscreen mode Exit fullscreen mode

The trick here is video.onseeked. When you set currentTime, the browser doesn't jump to that frame instantly — it's async. You have to wait for the seeked event before drawing to the canvas, or you'll grab stale frames.

Performance Gotcha

Extracting frames from a 30-second video at 10fps gives you 300 frames. Each one is a full ImageData object sitting in memory. For a 1080p video, that's roughly 1920 * 1080 * 4 bytes = ~8MB per frame. 300 frames = 2.4GB of raw pixel data.

So you have to be smart about it. I scale the video down before extracting:

const maxWidth = 480;
const scale = Math.min(1, maxWidth / video.videoWidth);
canvas.width = video.videoWidth * scale;
canvas.height = video.videoHeight * scale;
Enter fullscreen mode Exit fullscreen mode

480px wide is plenty for most GIFs, and it cuts memory usage by 75% compared to 1080p.

The GIF Encoder: LZW Compression from Scratch

This is where it gets interesting. The GIF format uses LZW (Lempel-Ziv-Welch) compression, and I implemented it without pulling in any library.

GIF files follow the GIF89a spec. The binary structure looks like this:

  1. Header: GIF89a (6 bytes)
  2. Logical Screen Descriptor: width, height, color table info (7 bytes)
  3. Global Color Table: up to 256 RGB colors
  4. Netscape Extension: for looping (19 bytes)
  5. For each frame: Graphic Control Extension + Image Data
  6. Trailer: 0x3B (1 byte)

The Netscape Application Extension is what makes the GIF loop. Without it, the animation plays once and stops:

function writeNetscapeExtension(buffer, offset) {
  buffer[offset++] = 0x21; // Extension introducer
  buffer[offset++] = 0xFF; // Application extension
  buffer[offset++] = 0x0B; // Block size
  // "NETSCAPE2.0"
  const id = 'NETSCAPE2.0';
  for (let i = 0; i < 11; i++) {
    buffer[offset++] = id.charCodeAt(i);
  }
  buffer[offset++] = 0x03; // Sub-block size
  buffer[offset++] = 0x01; // Loop indicator
  buffer[offset++] = 0x00; // Loop count (low byte) — 0 = infinite
  buffer[offset++] = 0x00; // Loop count (high byte)
  buffer[offset++] = 0x00; // Block terminator
  return offset;
}
Enter fullscreen mode Exit fullscreen mode

The LZW compression itself works by building a dictionary of pixel patterns as it scans through the image:

function lzwEncode(pixels, colorDepth) {
  const minCodeSize = Math.max(2, colorDepth);
  const clearCode = 1 << minCodeSize;
  const eoiCode = clearCode + 1;

  let codeSize = minCodeSize + 1;
  let nextCode = eoiCode + 1;
  const table = new Map();

  // Initialize dictionary with single-character codes
  for (let i = 0; i < clearCode; i++) {
    table.set(String(i), i);
  }

  const output = [];
  output.push(clearCode);

  let current = String(pixels[0]);

  for (let i = 1; i < pixels.length; i++) {
    const next = String(pixels[i]);
    const combined = current + ',' + next;

    if (table.has(combined)) {
      current = combined;
    } else {
      output.push(table.get(current));
      if (nextCode < 4096) {
        table.set(combined, nextCode++);
        if (nextCode > (1 << codeSize) && codeSize < 12) {
          codeSize++;
        }
      } else {
        output.push(clearCode);
        table.clear();
        for (let j = 0; j < clearCode; j++) {
          table.set(String(j), j);
        }
        codeSize = minCodeSize + 1;
        nextCode = eoiCode + 1;
      }
      current = next;
    }
  }

  output.push(table.get(current));
  output.push(eoiCode);

  return { codes: output, minCodeSize };
}
Enter fullscreen mode Exit fullscreen mode

The 4096 limit is baked into the GIF spec — codes are 12 bits max. When you hit the limit, you emit a clear code and reset the dictionary.

Color Quantization

GIF only supports 256 colors per frame. Video frames have millions. You need a quantization step to map the full RGB space down to a 256-color palette.

I went with median-cut quantization. It works by repeatedly splitting the RGB color space along whichever axis has the widest range:

function medianCut(pixels, maxColors) {
  let buckets = [{ colors: pixels }];

  while (buckets.length < maxColors) {
    // Find the bucket with the widest color range
    let widest = 0, widestRange = 0, widestChannel = 0;

    for (let i = 0; i < buckets.length; i++) {
      for (let ch = 0; ch < 3; ch++) {
        const vals = buckets[i].colors.map(c => c[ch]);
        const range = Math.max(...vals) - Math.min(...vals);
        if (range > widestRange) {
          widestRange = range;
          widest = i;
          widestChannel = ch;
        }
      }
    }

    const bucket = buckets.splice(widest, 1)[0];
    bucket.colors.sort((a, b) => a[widestChannel] - b[widestChannel]);
    const mid = Math.floor(bucket.colors.length / 2);
    buckets.push({ colors: bucket.colors.slice(0, mid) });
    buckets.push({ colors: bucket.colors.slice(mid) });
  }

  return buckets.map(b => {
    const avg = [0, 0, 0];
    b.colors.forEach(c => { avg[0] += c[0]; avg[1] += c[1]; avg[2] += c[2]; });
    return avg.map(v => Math.round(v / b.colors.length));
  });
}
Enter fullscreen mode Exit fullscreen mode

This gives way better results than just picking the most frequent colors. The palette adapts to the actual color distribution in each frame.

Putting It All Together

The final pipeline: extract frames → quantize colors → encode each frame with LZW → pack into GIF binary.

I added a progress bar since encoding can take 5-10 seconds for longer videos. Users seeing a frozen screen is a guaranteed bounce.

The whole thing runs in the main thread, which isn't ideal. Moving the encoder into a Web Worker would keep the UI responsive during heavy encodes — that's on my list.

Try It

The converter is live at devtools-site-delta.vercel.app/video-to-gif. Drop a video file, pick your FPS and quality, and it spits out a GIF. Nothing gets uploaded anywhere.

If you're building something similar, the biggest lesson I learned: get the color quantization right first. Bad palettes make everything else look broken even if your encoder is perfect.

Top comments (0)