Skip to content

GIF Frame Extractor — Java 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 Java implementation — the same logic the interactive tool runs, in a shareable, citable form.

// GIF Frame Extractor — decode a GIF byte stream into indexed frames.
//
// Language: Java (17+, standard library only)
// CosmoDev polyglot showcase port of the `gif-frame-extractor` tool.
// Ported from src/lib/gif-decode.ts — display source, part of CosmoDev's
// polyglot tool pages.
//
// A from-scratch GIF87a/89a parser: signature + logical screen descriptor
// (canvas size, global color table), extension blocks (frame delay,
// transparency, NETSCAPE loop count), and LZW image data decompressed to
// palette indices — interlaced frames reordered to natural row order.
// Malformed input returns null (same contract as the TS reference).

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public final class GifFrameExtractor {

    /** One decoded image: a rect on the canvas plus its palette-index data. */
    public record GifFrame(int x, int y, int width, int height,
                           byte[] palette,       // RGB triplets; null = global.
                           byte[] indices,       // Pixel indices, natural row order.
                           int delayMs, int transparentIndex, int disposal) { }

    /** Whole-file result: screen size, frames, palettes, loop count. */
    public record GifResult(int width, int height, List<GifFrame> frames,
                            byte[] globalPalette,
                            int loopCount) { } // 0 = forever, -1 when absent.

    // ── GIF LZW decompression ─────────────────────────────────────────────

    /** Read one code LSB-first; returns EOI on truncation, like the TS decoder. */
    private static int readCode(byte[] data, int[] bitPos, int codeSize, int eoiCode) {
        if ((bitPos[0] + codeSize) >>> 3 > data.length) return eoiCode;
        int code = 0;
        for (int i = 0; i < codeSize; i++) {
            int byteIdx = (bitPos[0] + i) >>> 3;
            if (byteIdx >= data.length) return eoiCode;
            code |= ((data[byteIdx] >>> ((bitPos[0] + i) & 7)) & 1) << i;
        }
        bitPos[0] += codeSize;
        return code;
    }

    /** Emit a code's chain; returns the chain's FIRST byte (needed for KwKwK). */
    private static int emitChain(int code, int[] prefix, int[] suffix,
                                 int[] first, List<Integer> out) {
        int[] stack = new int[4096];
        int n = 0, c = code;
        while (c >= 0) { stack[n++] = suffix[c]; c = prefix[c]; }
        for (int i = n - 1; i >= 0; i--) out.add(stack[i]);
        return stack[n - 1];
    }

    /** minCodeSize 2-8, clear-code resets, growing codes — same contract as TS. */
    public static byte[] lzwDecode(int minCodeSize, byte[] data) {
        int clearCode = 1 << minCodeSize, eoiCode = clearCode + 1;
        int codeSize = minCodeSize + 1, nextCode = eoiCode + 1;
        // Dictionary as (prefix, suffix, first-byte) triples, reset per clear.
        int[] prefix = new int[4096], suffix = new int[4096], first = new int[4096];
        List<Integer> out = new ArrayList<>();
        int[] bitPos = {0};
        int prev = -1;
        for (;;) {
            int code = readCode(data, bitPos, codeSize, eoiCode);
            if (code == eoiCode) break;
            if (code == clearCode) {
                for (int i = 0; i < clearCode; i++) { prefix[i] = -1; suffix[i] = i; first[i] = i; }
                nextCode = eoiCode + 1; codeSize = minCodeSize + 1;
                prev = -1; continue;
            }
            if (prev == -1) {
                if (code >= clearCode) break; // First code after clear is a literal.
                emitChain(code, prefix, suffix, first, out); prev = code; continue;
            }
            if (code > nextCode) break;       // Invalid — stop like browsers do.
            // KwKwK: a code one ahead of the dictionary is prev + first(prev).
            int emittedFirst;
            if (code == nextCode) {
                emittedFirst = emitChain(prev, prefix, suffix, first, out);
                out.add(first[prev]);
                emittedFirst = first[prev];
            } else {
                emittedFirst = emitChain(code, prefix, suffix, first, out);
            }
            if (nextCode < 4096) { // Table full: TS silently no-ops this write.
                prefix[nextCode] = prev; suffix[nextCode] = emittedFirst;
                first[nextCode] = first[prev];
                nextCode++;
                if (nextCode == (1 << codeSize) && codeSize < 12) codeSize++;
            }
            prev = code;
        }
        byte[] bytes = new byte[out.size()];
        for (int i = 0; i < bytes.length; i++) bytes[i] = (byte) out.get(i).intValue();
        return bytes;
    }

    // ── Interlace ──────────────────────────────────────────────────────────

    /** Reorder interlaced rows into natural order; identity for short frames. */
    public static byte[] deInterlace(byte[] indices, int width, int height) {
        if (height < 4 || width == 0) return indices;
        byte[] out = new byte[indices.length];
        int src = 0; // Stored pass-by-pass: four passes, starts/steps below.
        int[][] passes = { {0, 8}, {4, 8}, {2, 4}, {1, 2} };
        for (int[] p : passes) {
            for (int row = p[0]; row < height; row += p[1]) {
                System.arraycopy(indices, src, out, row * width, width);
                src += width;
            }
        }
        return out;
    }

    // ── Whole-GIF parse ────────────────────────────────────────────────────

    private static int le16(byte[] b, int p) { return (b[p] & 0xff) | ((b[p + 1] & 0xff) << 8); }

    /** Concatenate a sub-block chain; null on truncation. */
    private static byte[] readSubBlocks(byte[] b, int[] pos) {
        List<byte[]> chunks = new ArrayList<>();
        int total = 0;
        for (;;) {
            if (pos[0] >= b.length) return null;
            int size = b[pos[0]++] & 0xff;
            if (size == 0) break; // Terminator ends the chain.
            if (pos[0] + size > b.length) return null;
            chunks.add(Arrays.copyOfRange(b, pos[0], pos[0] + size));
            total += size;
            pos[0] += size;
        }
        byte[] out = new byte[total];
        int o = 0;
        for (byte[] c : chunks) { System.arraycopy(c, 0, out, o, c.length); o += c.length; }
        return out;
    }

    /** Parse signature, screen descriptor, extensions, and all frames. */
    public static GifResult decodeGif(byte[] bytes) {
        if (bytes.length < 13) return null;
        String magic = new String(bytes, 0, 6, java.nio.charset.StandardCharsets.US_ASCII);
        if (!magic.equals("GIF87a") && !magic.equals("GIF89a")) return null;
        // Logical screen descriptor: canvas size, flags, optional global palette.
        int[] pos = {6};
        int width = le16(bytes, pos[0]), height = le16(bytes, pos[0] + 2);
        int packed = bytes[pos[0] + 4] & 0xff;
        pos[0] += 7; // Skip bg color + aspect ratio.
        byte[] globalPalette = null;
        if ((packed & 0x80) != 0) {
            int n = (2 << (packed & 7)) * 3;
            if (pos[0] + n > bytes.length) return null;
            globalPalette = Arrays.copyOfRange(bytes, pos[0], pos[0] + n);
            pos[0] += n;
        }
        List<GifFrame> frames = new ArrayList<>();
        int loopCount = -1; // Absent until a NETSCAPE block says otherwise.
        int delayMs = 0, transparentIndex = -1, disposal = 0;

        for (;;) {
            if (pos[0] >= bytes.length) return null;
            int block = bytes[pos[0]++] & 0xff;
            if (block == 0x3b) break; // trailer
            if (block == 0x21) {      // Extension: graphic control / NETSCAPE / skip.
                if (pos[0] >= bytes.length) return null;
                int label = bytes[pos[0]++] & 0xff;
                if (label == 0xf9) {
                    byte[] gce = readSubBlocks(bytes, pos);
                    if (gce == null || gce.length < 4) return null;
                    disposal = (gce[0] >> 2) & 7;
                    delayMs = le16(gce, 1) * 10;
                    transparentIndex = (gce[0] & 1) != 0 ? gce[3] & 0xff : -1;
                } else if (label == 0xff) {
                    byte[] app = readSubBlocks(bytes, pos);
                    // Concatenated: 11-byte name, then id 1 + loop lo/hi.
                    if (app != null && app.length >= 14 && app[11] == 1
                            && new String(app, 0, 11,
                                          java.nio.charset.StandardCharsets.US_ASCII)
                                   .equals("NETSCAPE2.0"))
                        loopCount = le16(app, 12);
                } else if (readSubBlocks(bytes, pos) == null) {
                    return null;
                }
                continue;
            }
            if (block == 0x2c) { // Image descriptor: rect, local palette, LZW data.
                if (pos[0] + 9 > bytes.length) return null;
                int x = le16(bytes, pos[0]), y = le16(bytes, pos[0] + 2);
                int w = le16(bytes, pos[0] + 4), h = le16(bytes, pos[0] + 6);
                int ip = bytes[pos[0] + 8] & 0xff;
                pos[0] += 9; // Descriptor is 9 bytes: x, y, w, h, packed flags.
                byte[] palette = null;
                if ((ip & 0x80) != 0) {
                    int n = (2 << (ip & 7)) * 3;
                    if (pos[0] + n > bytes.length) return null;
                    palette = Arrays.copyOfRange(bytes, pos[0], pos[0] + n);
                    pos[0] += n;
                }
                if (pos[0] >= bytes.length) return null;
                int minCodeSize = bytes[pos[0]++] & 0xff;
                // LZW payload must be a complete sub-block chain.
                byte[] data = readSubBlocks(bytes, pos);
                if (data == null) return null;
                byte[] indices = lzwDecode(minCodeSize, data);
                if ((ip & 0x40) != 0) indices = deInterlace(indices, w, h);
                frames.add(new GifFrame(x, y, w, h, palette, indices,
                                        delayMs, transparentIndex, disposal));
                delayMs = 0; transparentIndex = -1; disposal = 0;
                continue;
            }
            return null; // Unknown block type — bail.
        }
        return new GifResult(width, height, frames, globalPalette, loopCount);
    }

    public static void main(String[] args) {
        // 2×1 GIF89a: 2-color global palette (red, blue), one frame, pixels [0,1].
        byte[] gif = {
            'G','I','F','8','9','a', 0x02,0x00, 0x01,0x00, (byte)0x80, 0x00, 0x00,
            (byte)0xff,0x00,0x00, 0x00,0x00,(byte)0xff,
            0x2c, 0x00,0x00, 0x00,0x00, 0x02,0x00, 0x01,0x00, 0x00,
            0x02, 0x02, 0x44,0x0a, 0x00, 0x3b
        };
        GifResult res = decodeGif(gif);
        if (res == null) { System.err.println("malformed GIF"); return; }
        System.out.printf("%dx%d, %d frame(s), loop=%d%n",
            res.width(), res.height(), res.frames().size(), res.loopCount());
        for (int i = 0; i < res.frames().size(); i++) {
            GifFrame f = res.frames().get(i);
            StringBuilder idx = new StringBuilder();
            for (int j = 0; j < f.indices().length; j++)
                idx.append(j > 0 ? ", " : "").append(f.indices()[j] & 0xff);
            System.out.printf("frame %d: %dx%d at (%d,%d), delay %dms, indices [%s]%n",
                i, f.width(), f.height(), f.x(), f.y(), f.delayMs(), idx);
        }
    }

    private GifFrameExtractor() { }
}

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 →