Skip to content

GIF Frame Extractor — TypeScript source

Split an animated GIF into PNG frames with per-frame delays — decoded by our own pure GIF parser, entirely in your browser. Nothing uploads.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Pure GIF87a/89a decoder — no React, no DOM. Parses the byte stream and
// LZW-decodes every frame to indexed pixels. Deterministic and side-effect
// free; truncated or malformed input returns null instead of throwing.

export interface GifFrame {
  x: number;
  y: number;
  width: number;
  height: number;
  /** Palette override for this frame (RGB triplets), null = use global. */
  palette: Uint8Array | null;
  /** Pixel indices in natural row order (de-interlaced). */
  indices: Uint8Array;
  /** Frame delay in milliseconds. */
  delayMs: number;
  /** Transparent palette index, or -1. */
  transparentIndex: number;
  /** Disposal method 0-7 (0/1 = keep, 2 = restore bg, 3 = restore previous). */
  disposal: number;
}

export interface GifResult {
  width: number;
  height: number;
  frames: GifFrame[];
  globalPalette: Uint8Array | null;
  /** NETSCAPE loop count; 0 means loop forever, Infinity when absent (play once semantics differ per consumer). */
  loopCount: number;
}

/** GIF LZW decompression — minCodeSize 2-8, clear-code resets, growing codes. */
export function lzwDecode(minCodeSize: number, data: Uint8Array): Uint8Array {
  const clearCode = 1 << minCodeSize;
  const eoiCode = clearCode + 1;
  let codeSize = minCodeSize + 1;
  let nextCode = eoiCode + 1;

  // Dictionary as (prefix, suffix, first-byte) triples — reset per clear code.
  const prefix = new Int32Array(4096);
  const suffix = new Uint8Array(4096);
  const first = new Uint8Array(4096);
  const resetDict = () => {
    for (let i = 0; i < clearCode; i++) {
      prefix[i] = -1;
      suffix[i] = i;
      first[i] = i;
    }
    nextCode = eoiCode + 1;
    codeSize = minCodeSize + 1;
  };
  resetDict();

  const out: number[] = [];
  /** Emit a code's chain, returning the chain's FIRST byte (for KwKwK). */
  const emit = (code: number): number => {
    const stack: number[] = [];
    let c = code;
    while (c >= 0) {
      stack.push(suffix[c]);
      c = prefix[c];
    }
    for (let i = stack.length - 1; i >= 0; i--) out.push(stack[i]);
    return stack[stack.length - 1];
  };

  let bitPos = 0;
  const readCode = (): number => {
    if ((bitPos + codeSize) >> 3 > data.length) return eoiCode;
    let code = 0;
    for (let i = 0; i < codeSize; i++) {
      const byteIdx = (bitPos + i) >> 3;
      if (byteIdx >= data.length) return eoiCode;
      const bit = (data[byteIdx] >> ((bitPos + i) & 7)) & 1;
      code |= bit << i;
    }
    bitPos += codeSize;
    return code;
  };

  let prev = -1;
  for (;;) {
    const code = readCode();
    if (code === eoiCode) break;
    if (code === clearCode) {
      resetDict();
      prev = -1;
      continue;
    }
    if (prev === -1) {
      if (code >= clearCode) break; // First code after clear must be a literal.
      emit(code);
      prev = code;
      continue;
    }
    if (code > nextCode) break; // Invalid — stop like browsers do.
    // KwKwK: code one ahead of the dictionary decodes to prev + first(prev).
    const emittedFirst = code === nextCode ? (emit(prev), out.push(first[prev]), first[prev]) : emit(code);
    prefix[nextCode] = prev;
    suffix[nextCode] = emittedFirst;
    first[nextCode] = first[prev];
    nextCode++;
    prev = code;
    if (nextCode === 1 << codeSize && codeSize < 12) codeSize++;
  }
  return Uint8Array.from(out);
}

/** Reorder interlaced rows into natural order. Identity for progressive rows. */
export function deInterlace(indices: Uint8Array, width: number, height: number): Uint8Array {
  if (height < 4 || width === 0) return indices;
  const out = new Uint8Array(indices.length);
  const passes = [
    { start: 0, step: 8 },
    { start: 4, step: 8 },
    { start: 2, step: 4 },
    { start: 1, step: 2 },
  ];
  let src = 0;
  for (const pass of passes) {
    for (let row = pass.start; row < height; row += pass.step) {
      out.set(indices.subarray(src, src + width), row * width);
      src += width;
    }
  }
  return out;
}

export function decodeGif(bytes: Uint8Array): GifResult | null {
  if (bytes.length < 13) return null;
  const magic = String.fromCharCode(...bytes.subarray(0, 6));
  if (magic !== 'GIF87a' && magic !== 'GIF89a') return null;

  let pos = 6;
  const width = bytes[pos] | (bytes[pos + 1] << 8);
  const height = bytes[pos + 2] | (bytes[pos + 3] << 8);
  const packed = bytes[pos + 4];
  pos += 7;

  let globalPalette: Uint8Array | null = null;
  if (packed & 0x80) {
    const entries = 2 << (packed & 7);
    globalPalette = bytes.slice(pos, pos + entries * 3);
    if (globalPalette.length < entries * 3) return null;
    pos += entries * 3;
  }

  const frames: GifFrame[] = [];
  let loopCount = Infinity;
  let delayMs = 0;
  let transparentIndex = -1;
  let disposal = 0;

  const readSubBlocks = (): Uint8Array | null => {
    const chunks: Uint8Array[] = [];
    for (;;) {
      if (pos >= bytes.length) return null;
      const size = bytes[pos++];
      if (size === 0) break;
      if (pos + size > bytes.length) return null;
      chunks.push(bytes.subarray(pos, pos + size));
      pos += size;
    }
    const total = chunks.reduce((s, c) => s + c.length, 0);
    const out = new Uint8Array(total);
    let o = 0;
    for (const c of chunks) {
      out.set(c, o);
      o += c.length;
    }
    return out;
  };

  for (;;) {
    if (pos >= bytes.length) return null;
    const block = bytes[pos++];
    if (block === 0x3b) break; // trailer

    if (block === 0x21) {
      // Extension
      if (pos >= bytes.length) return null;
      const label = bytes[pos++];
      if (label === 0xf9) {
        const gce = readSubBlocks();
        if (!gce || gce.length < 4) return null;
        disposal = (gce[0] >> 2) & 7;
        delayMs = (gce[1] | (gce[2] << 8)) * 10;
        transparentIndex = gce[0] & 1 ? gce[3] : -1;
      } else if (label === 0xff) {
        const app = readSubBlocks();
        if (app && app.length >= 14) {
          // Sub-block contents concatenated: the 11-byte name, then id 1 + loop lo/hi.
          const name = String.fromCharCode(...app.subarray(0, 11));
          if (name === 'NETSCAPE2.0' && app[11] === 1) {
            loopCount = app[12] | (app[13] << 8);
          }
        }
      } else {
        if (!readSubBlocks()) return null;
      }
      continue;
    }

    if (block === 0x2c) {
      // Image descriptor
      if (pos + 9 > bytes.length) return null;
      const x = bytes[pos] | (bytes[pos + 1] << 8);
      const y = bytes[pos + 2] | (bytes[pos + 3] << 8);
      const w = bytes[pos + 4] | (bytes[pos + 5] << 8);
      const h = bytes[pos + 6] | (bytes[pos + 7] << 8);
      const ip = bytes[pos + 8];
      pos += 9;
      let palette: Uint8Array | null = null;
      if (ip & 0x80) {
        const entries = 2 << (ip & 7);
        palette = bytes.slice(pos, pos + entries * 3);
        if (palette.length < entries * 3) return null;
        pos += entries * 3;
      }
      if (pos >= bytes.length) return null;
      const minCodeSize = bytes[pos++];
      const data = readSubBlocks();
      if (!data) return null;
      const indices = lzwDecode(minCodeSize, data);
      const final = ip & 0x40 ? deInterlace(indices, w, h) : indices;
      frames.push({
        x,
        y,
        width: w,
        height: h,
        palette,
        indices: final,
        delayMs,
        transparentIndex,
        disposal,
      });
      delayMs = 0;
      transparentIndex = -1;
      disposal = 0;
      continue;
    }

    return null; // Unknown block type — bail.
  }

  return { width, height, frames, globalPalette, loopCount };
}

Also available in 9 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →