Skip to content

Video to GIF Converter — Java source

Convert a video clip to an animated GIF — frame capture, palette quantization and GIF encoding all run locally with our own encoder. Nothing uploads.

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

// Video to GIF Converter — a pure GIF89a encoder core.
//
// Language: Java (17+, standard library only)
// CosmoDev polyglot showcase port of the `video-to-gif` tool.
// Ported from src/lib/gif-encode.ts (with its palette-extract dependency
// inlined) — display source, part of CosmoDev's polyglot tool pages.
//
// No video decode — frames arrive as RGBA buffers. One shared palette is
// quantized from a down-sampled mix of ALL frames (median cut), pixels are
// mapped to palette indices via an exact-match cache plus nearest-RGB
// distance, and the index stream is LZW-compressed GIF-style (LSB-first
// codes, 9..12-bit widths, clear-code dictionary resets) into sub-blocks.
//
// Java trap: bytes are SIGNED — every stored byte is masked with & 0xff on
// read, and palette indices are written as (byte) i. Trap two: the encoder's
// dictionary runs one entry AHEAD of the decoder's, so the code width grows
// one entry later than intuition says — right after adding code 2^codeSize.

import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public final class VideoToGif {

    /** One frame: RGBA pixels (top-left origin) plus its delay. */
    public record GifFrameInput(int width, int height, byte[] rgba, int delayMs) { }

    /** Palette entry from the median cut. */
    public record Swatch(int r, int g, int b, int population) { }

    private record Pixel(int r, int g, int b) { }

    /** Palette size ceiling; GIF caps a color table at 256 entries. */
    private static final int MAX_COLORS = 128;

    // ── Median-cut palette (inlined from palette-extract.ts) ───────────────

    private static int[] bounds(List<Pixel> bucket) {
        int mnR = 255, mxR = 0, mnG = 255, mxG = 0, mnB = 255, mxB = 0;
        for (Pixel p : bucket) {
            if (p.r < mnR) mnR = p.r; if (p.r > mxR) mxR = p.r;
            if (p.g < mnG) mnG = p.g; if (p.g > mxG) mxG = p.g;
            if (p.b < mnB) mnB = p.b; if (p.b > mxB) mxB = p.b;
        }
        return new int[] { mxR - mnR, mxG - mnG, mxB - mnB };
    }

    /** Split a bucket at the median of its widest channel (stable, deterministic). */
    private static List<Pixel>[] splitBucket(List<Pixel> bucket) {
        int[] b = bounds(bucket);
        int channel = b[1] > b[0] ? 1 : 0;
        if (b[2] > b[channel]) channel = 2;
        final int ch = channel;
        Comparator<Pixel> byChannel = switch (ch) {
            case 0 -> Comparator.comparingInt(Pixel::r);
            case 1 -> Comparator.comparingInt(Pixel::g);
            default -> Comparator.comparingInt(Pixel::b);
        };
        List<Pixel> sorted = new ArrayList<>(bucket);
        sorted.sort(byChannel);
        int mid = sorted.size() / 2;
        @SuppressWarnings("unchecked")
        List<Pixel>[] halves = new List[] {
                new ArrayList<>(sorted.subList(0, mid)),
                new ArrayList<>(sorted.subList(mid, sorted.size())) };
        return halves;
    }

    private static List<Swatch> extractPalette(byte[] rgba, int maxColors) {
        int total = rgba.length / 4;
        List<Pixel> pixels = new ArrayList<>();
        int stride = Math.max(1, total / 16384); // bounded samples
        for (int i = 0; i < total; i += stride) {
            int o = i * 4;
            if ((rgba[o + 3] & 0xff) == 0) continue;
            pixels.add(new Pixel(rgba[o] & 0xff, rgba[o + 1] & 0xff, rgba[o + 2] & 0xff));
        }
        if (pixels.isEmpty()) return List.of();

        List<List<Pixel>> buckets = new ArrayList<>(List.of(pixels));
        while (buckets.size() < maxColors) {
            int bestIdx = -1, bestRange = 1; // range 1 (duplicates only) never splits
            for (int i = 0; i < buckets.size(); i++) {
                int[] b = bounds(buckets.get(i));
                int range = Math.max(b[0], Math.max(b[1], b[2]));
                if (range > bestRange) { bestRange = range; bestIdx = i; }
            }
            if (bestIdx == -1) break;
            buckets.addAll(List.of(splitBucket(buckets.remove(bestIdx))));
        }
        List<Swatch> out = new ArrayList<>();
        for (List<Pixel> bucket : buckets) {
            if (bucket.isEmpty()) continue;
            long sr = 0, sg = 0, sb = 0;
            for (Pixel p : bucket) { sr += p.r; sg += p.g; sb += p.b; }
            out.add(new Swatch((int) Math.round((double) sr / bucket.size()),
                               (int) Math.round((double) sg / bucket.size()),
                               (int) Math.round((double) sb / bucket.size()),
                               bucket.size()));
        }
        out.sort(Comparator.comparingInt(Swatch::population).reversed());
        return out;
    }

    // ── LZW compression (GIF variant) ──────────────────────────────────────

    /** Pack codes LSB-first at the current width; returns the raw byte stream. */
    public static byte[] lzwEncode(int minCodeSize, byte[] indices) {
        int clearCode = 1 << minCodeSize, eoiCode = clearCode + 1;
        int[] codeSize = { minCodeSize + 1 };
        int[] nextCode = { eoiCode + 1 };

        // Dictionary: (prefixCode << 8 | byte) -> code, keyed numerically.
        java.util.HashMap<Integer, Integer> dict = new java.util.HashMap<>();
        Runnable resetDict = () -> {
            dict.clear();
            nextCode[0] = eoiCode + 1;
            codeSize[0] = minCodeSize + 1;
        };
        resetDict.run();

        ByteArrayOutputStream out = new ByteArrayOutputStream();
        long[] bitBuffer = { 0 }; // long: codeSize + 7 leftover bits always fits
        int[] bitCount = { 0 };
        Runnable flush = () -> { if (bitCount[0] > 0) out.write((int) (bitBuffer[0] & 0xff)); };
        java.util.function.IntConsumer emit = code -> {
            bitBuffer[0] |= (long) code << bitCount[0];
            bitCount[0] += codeSize[0];
            while (bitCount[0] >= 8) {
                out.write((int) (bitBuffer[0] & 0xff));
                bitBuffer[0] >>>= 8;
                bitCount[0] -= 8;
            }
        };
        // The encoder's dictionary runs one entry AHEAD of the decoder's, so
        // the width grows one entry later: right after adding code 2^codeSize.
        Runnable growIfDue = () -> {
            if (nextCode[0] - 1 == (1 << codeSize[0]) && codeSize[0] < 12) codeSize[0]++;
        };

        emit.accept(clearCode);
        if (indices.length == 0) {
            emit.accept(eoiCode);
            flush.run();
            return out.toByteArray();
        }

        int w = indices[0] & 0xff;
        for (int i = 1; i < indices.length; i++) {
            int c = indices[i] & 0xff;
            int key = (w << 8) | c;
            Integer found = dict.get(key);
            if (found != null) { w = found; continue; }
            emit.accept(w);
            dict.put(key, nextCode[0]++);
            growIfDue.run();
            w = c;
            if (nextCode[0] >= 4096) { // Dictionary full — reset like encoders do.
                emit.accept(clearCode);
                resetDict.run();
                w = c;
            }
        }
        emit.accept(w);
        emit.accept(eoiCode);
        flush.run();
        return out.toByteArray();
    }

    // ── Palette mapping ────────────────────────────────────────────────────

    /** Map RGBA to palette indices via exact cache + nearest RGB distance. */
    private static byte[] mapToPalette(byte[] rgba, List<Swatch> palette) {
        java.util.HashMap<Integer, Integer> cache = new java.util.HashMap<>();
        byte[] indices = new byte[rgba.length / 4];
        for (int i = 0; i < indices.length; i++) {
            int o = i * 4;
            int key = ((rgba[o] & 0xff) << 16) | ((rgba[o + 1] & 0xff) << 8) | (rgba[o + 2] & 0xff);
            Integer idx = cache.get(key);
            if (idx == null) {
                int best = 0;
                long bestDist = Long.MAX_VALUE;
                for (int p = 0; p < palette.size(); p++) {
                    int dr = (rgba[o] & 0xff) - palette.get(p).r;
                    int dg = (rgba[o + 1] & 0xff) - palette.get(p).g;
                    int db = (rgba[o + 2] & 0xff) - palette.get(p).b;
                    long dist = (long) dr * dr + (long) dg * dg + (long) db * db;
                    if (dist < bestDist) { bestDist = dist; best = p; }
                }
                idx = best;
                cache.put(key, idx);
            }
            indices[i] = (byte) idx.intValue();
        }
        return indices;
    }

    // ── Byte assembly ──────────────────────────────────────────────────────

    private static void u16le(ByteArrayOutputStream out, int n) {
        out.write(n & 0xff);
        out.write((n >> 8) & 0xff);
    }

    public static byte[] encodeGif(List<GifFrameInput> frames) {
        if (frames.isEmpty()) return new byte[0];

        // One shared palette, quantized from a down-sampled mix of all frames.
        ByteArrayOutputStream mixed = new ByteArrayOutputStream();
        for (GifFrameInput f : frames) {
            int total = f.rgba.length / 4;
            int stride = Math.max(1, total / 4096);
            for (int i = 0; i < total; i += stride) {
                int o = i * 4;
                // write(int) keeps only the low 8 bits, so signed bytes are safe.
                mixed.write(f.rgba[o]);
                mixed.write(f.rgba[o + 1]);
                mixed.write(f.rgba[o + 2]);
                mixed.write(f.rgba[o + 3]);
            }
        }
        List<Swatch> palette = extractPalette(mixed.toByteArray(), MAX_COLORS);
        if (palette.isEmpty()) return new byte[0];

        // Palette table padded to a power of two (min 2 entries).
        int tableBits = 1;
        while ((1 << tableBits) < palette.size()) tableBits++;
        int tableSize = 1 << tableBits;

        ByteArrayOutputStream out = new ByteArrayOutputStream();
        out.writeBytes("GIF89a".getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
        int width = frames.get(0).width(), height = frames.get(0).height();
        u16le(out, width);
        u16le(out, height);
        out.write(0x80 | (tableBits - 1)); // GCT flag + size
        out.write(0);                       // background color index
        out.write(0);                       // pixel aspect ratio
        for (int i = 0; i < tableSize; i++) {
            Swatch sw = i < palette.size() ? palette.get(i) : null;
            out.write(sw != null ? sw.r() : 0);
            out.write(sw != null ? sw.g() : 0);
            out.write(sw != null ? sw.b() : 0);
        }

        // NETSCAPE loop forever.
        out.write(0x21); out.write(0xff); out.write(0x0b);
        out.writeBytes("NETSCAPE2.0".getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
        out.write(0x03); out.write(0x01); out.write(0x00); out.write(0x00); out.write(0x00);

        int minCodeSize = Math.max(2, tableBits);
        for (GifFrameInput frame : frames) {
            // Graphic control extension: delay in centiseconds, no transparency.
            int cs = (int) Math.max(0, Math.min(0xffff, Math.round(frame.delayMs() / 10.0)));
            out.write(0x21); out.write(0xf9); out.write(0x04); out.write(0x00);
            u16le(out, cs);
            out.write(0x00); out.write(0x00);

            out.write(0x2c); // image descriptor
            u16le(out, 0); u16le(out, 0);
            u16le(out, frame.width()); u16le(out, frame.height());
            out.write(0x00); // no LCT, no interlace

            byte[] data = lzwEncode(minCodeSize, mapToPalette(frame.rgba(), palette));
            out.write(minCodeSize);
            for (int i = 0; i < data.length; i += 255) { // sub-blocks of <= 255
                int chunk = Math.min(255, data.length - i);
                out.write(chunk);
                out.write(data, i, chunk);
            }
            out.write(0x00); // block terminator
        }

        out.write(0x3b); // trailer
        return out.toByteArray();
    }

    public static void main(String[] args) {
        // Two 2×1 frames alternating red/blue and blue/red at 100 ms.
        byte[] f0 = { (byte) 200, 0, 0, (byte) 255, 0, 0, (byte) 200, (byte) 255 };
        byte[] f1 = { 0, 0, (byte) 200, (byte) 255, (byte) 200, 0, 0, (byte) 255 };
        byte[] gif = encodeGif(List.of(
                new GifFrameInput(2, 1, f0, 100),
                new GifFrameInput(2, 1, f1, 100)));
        System.out.println("encoded " + gif.length + " bytes");
        for (int i = 0; i < 6; i++) System.out.print((char) gif[i]);
        System.out.println();
    }
}

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 →