Skip to content

Video to GIF Converter — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Video to GIF Converter — a pure GIF89a encoder core.
//
// Language: Kotlin 1.9+ (JVM), standard library only.
// Source:   CosmoDev polyglot showcase port of the `video-to-gif` tool —
//           the same encoder ships as the Go CLI twin. Ported from
//           src/lib/gif-encode.ts (the canonical TypeScript implementation).
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// One shared palette is median-cut quantized from a down-sampled mix of all
// frames, every frame is mapped onto it (exact-match cache, else nearest
// RGB), and the index stream is LZW-compressed with the GIF variant of the
// algorithm — codes packed LSB-first, width growing one step behind the
// dictionary. The bit accumulator uses a Long: JS gets away with a number
// there because its doubles hold 53 bits, but Kotlin Int would silently
// truncate at 32 and corrupt wide codes.
//
// Run: kotlinc kotlin.kt -include-runtime -d gif.jar && java -jar gif.jar

/** One frame: raw RGBA pixels plus its delay. */
data class GifFrameInput(
    val width: Int,
    val height: Int,
    val rgba: ByteArray, // RGBA, 4 bytes/pixel, top-left origin
    val delayMs: Int,    // stored in the file as centiseconds
)

/** One dominant color: averaged RGB plus the pixel count it represents. */
data class Swatch(val r: Int, val g: Int, val b: Int, val population: Int)

private class Pixel(val r: Int, val g: Int, val b: Int)

/** Per-channel spread of a pixel bucket. */
private class Bounds(val r: Int, val g: Int, val b: Int)

private fun bounds(bucket: List<Pixel>): Bounds {
    var minR = 255; var maxR = 0; var minG = 255; var maxG = 0; var minB = 255; var maxB = 0
    for (p in bucket) {
        if (p.r < minR) minR = p.r
        if (p.r > maxR) maxR = p.r
        if (p.g < minG) minG = p.g
        if (p.g > maxG) maxG = p.g
        if (p.b < minB) minB = p.b
        if (p.b > maxB) maxB = p.b
    }
    return Bounds(maxR - minR, maxG - minG, maxB - minB)
}

/** Split a bucket at the median of its widest channel (stable, deterministic). */
private fun splitBucket(bucket: List<Pixel>): Pair<List<Pixel>, List<Pixel>> {
    val b = bounds(bucket)
    val channel = when {
        b.g > b.r && b.g >= b.b -> 1
        b.b > b.r && b.b > b.g -> 2
        else -> 0
    }
    val sorted = bucket.sortedBy { when (channel) { 0 -> it.r; 1 -> it.g; else -> it.b } }
    val mid = sorted.size / 2
    return sorted.subList(0, mid).toList() to sorted.subList(mid, sorted.size).toList()
}

private fun avg(bucket: List<Pixel>, c: (Pixel) -> Int): Int =
    Math.round(bucket.sumOf(c).toDouble() / bucket.size).toInt()

/** Median-cut quantization: split the widest-range bucket until maxColors. */
fun extractPalette(rgba: ByteArray, maxColors: Int = 8): List<Swatch> {
    val total = rgba.size / 4
    val pixels = ArrayList<Pixel>(total)
    val stride = maxOf(1, total / 16384) // bounded samples
    var i = 0
    while (i < total) {
        val o = i * 4
        // Trap: JVM bytes are signed — 0xff alpha is -1, so mask before compare.
        if ((rgba[o + 3].toInt() and 0xff) > 0) { // skip transparent
            pixels.add(Pixel(rgba[o].toInt() and 0xff, rgba[o + 1].toInt() and 0xff, rgba[o + 2].toInt() and 0xff))
        }
        i += stride
    }
    if (pixels.isEmpty()) return emptyList()

    val buckets = ArrayList<List<Pixel>>(maxColors)
    buckets.add(pixels)
    while (buckets.size < maxColors) {
        var bestIdx = -1
        var bestRange = 1 // range 1 (duplicates only) never splits
        for (idx in buckets.indices) {
            val b = bounds(buckets[idx])
            val range = maxOf(b.r, b.g, b.b)
            if (range > bestRange) { bestRange = range; bestIdx = idx }
        }
        if (bestIdx == -1) break
        val (low, high) = splitBucket(buckets.removeAt(bestIdx))
        buckets.add(low)
        buckets.add(high)
    }

    return buckets
        .filter { it.isNotEmpty() }
        .map { b -> Swatch(avg(b) { it.r }, avg(b) { it.g }, avg(b) { it.b }, b.size) }
        .sortedByDescending { it.population } // populous colors get low indices
}

/** Map RGBA pixels to palette indices: exact-match cache, else nearest RGB. */
fun mapToPalette(rgba: ByteArray, palette: List<Swatch>): ByteArray {
    val cache = HashMap<Int, Int>()
    val indices = ByteArray(rgba.size / 4)
    for (i in indices.indices) {
        val o = i * 4
        val r = rgba[o].toInt() and 0xff
        val g = rgba[o + 1].toInt() and 0xff
        val b = rgba[o + 2].toInt() and 0xff
        val key = (r shl 16) or (g shl 8) or b
        val cached = cache[key]
        if (cached != null) { indices[i] = cached.toByte(); continue }
        var best = 0
        var bestDist = Int.MAX_VALUE
        for (p in palette.indices) {
            val dr = r - palette[p].r; val dg = g - palette[p].g; val db = b - palette[p].b
            val dist = dr * dr + dg * dg + db * db
            if (dist < bestDist) { bestDist = dist; best = p }
        }
        cache[key] = best
        indices[i] = best.toByte()
    }
    return indices
}

/** LZW compression (GIF variant): pack codes LSB-first at the current width. */
fun lzwEncode(minCodeSize: Int, indices: ByteArray): ByteArray {
    val clearCode = 1 shl minCodeSize
    val eoiCode = clearCode + 1
    var codeSize = minCodeSize + 1
    var nextCode = eoiCode + 1
    var dict = HashMap<Long, Int>() // (prefixCode shl 8) or byte -> code

    val out = ByteArrayList()
    var bitBuffer = 0L
    var bitCount = 0
    fun emit(code: Int) {
        bitBuffer = bitBuffer or (code.toLong() shl bitCount)
        bitCount += codeSize
        while (bitCount >= 8) { out.add((bitBuffer and 0xff).toInt().toByte()); bitBuffer = bitBuffer ushr 8; bitCount -= 8 }
    }

    emit(clearCode)
    if (indices.isEmpty()) { emit(eoiCode); if (bitCount > 0) out.add((bitBuffer and 0xff).toInt().toByte()); return out.toByteArray() }

    var w = indices[0].toInt() and 0xff
    for (i in 1 until indices.size) {
        val c = indices[i].toInt() and 0xff
        val key = (w.toLong() shl 8) or c.toLong()
        val found = dict[key]
        if (found != null) { w = found; continue }
        emit(w)
        dict[key] = nextCode++
        // Width lags one entry behind the dictionary: grow after adding 2^codeSize.
        if (nextCode - 1 == 1 shl codeSize && codeSize < 12) codeSize++
        w = c
        if (nextCode >= 4096) { emit(clearCode); dict = HashMap(); nextCode = eoiCode + 1; codeSize = minCodeSize + 1 } // full — reset
    }
    emit(w); emit(eoiCode)
    if (bitCount > 0) out.add((bitBuffer and 0xff).toInt().toByte())
    return out.toByteArray()
}

/** Tiny growable byte sink — java.io.ByteArrayOutputStream would also do. */
private class ByteArrayList {
    private var data = ByteArray(256)
    private var len = 0
    fun add(b: Byte) {
        if (len == data.size) data = data.copyOf(data.size * 2)
        data[len++] = b
    }
    fun toByteArray() = data.copyOf(len)
}

private fun pushAscii(out: ByteArrayList, text: String) {
    for (ch in text) out.add(ch.code.toByte())
}

private fun u16le(n: Int): ByteArray = byteArrayOf((n and 0xff).toByte(), ((n shr 8) and 0xff).toByte())

fun encodeGif(frames: List<GifFrameInput>, maxColors: Int = 128): ByteArray {
    if (frames.isEmpty()) return ByteArray(0)

    // One shared palette, quantized from a down-sampled mix of all frames.
    val mixed = ArrayList<Byte>(frames.size * 4096 * 4)
    for (f in frames) {
        val total = f.rgba.size / 4
        val stride = maxOf(1, total / 4096)
        var i = 0
        while (i < total) {
            val o = i * 4
            mixed.add(f.rgba[o]); mixed.add(f.rgba[o + 1]); mixed.add(f.rgba[o + 2]); mixed.add(f.rgba[o + 3])
            i += stride
        }
    }
    val palette = extractPalette(mixed.toByteArray(), minOf(maxColors, 256))
    if (palette.isEmpty()) return ByteArray(0)

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

    val out = ByteArrayList()
    pushAscii(out, "GIF89a")
    for (b in u16le(frames[0].width)) out.add(b)
    for (b in u16le(frames[0].height)) out.add(b)
    out.add(((0x80 or (tableBits - 1)).toByte())); out.add(0); out.add(0) // GCT flag + size; bg; aspect
    for (i in 0 until (1 shl tableBits)) {
        val s = palette.getOrNull(i)
        out.add((s?.r ?: 0).toByte()); out.add((s?.g ?: 0).toByte()); out.add((s?.b ?: 0).toByte())
    }
    out.add(0x21); out.add(0xff.toByte()); out.add(0x0b) // NETSCAPE loop-forever extension
    pushAscii(out, "NETSCAPE2.0")
    out.add(0x03); out.add(0x01); out.add(0); out.add(0); out.add(0)

    val minCodeSize = maxOf(2, tableBits)
    for (frame in frames) {
        val cs = (Math.round(frame.delayMs / 10.0).toInt()).coerceIn(0, 0xffff) // centiseconds
        out.add(0x21); out.add(0xf9.toByte()); out.add(0x04); out.add(0) // graphic control ext
        for (b in u16le(cs)) out.add(b)
        out.add(0); out.add(0)
        out.add(0x2c)
        for (b in u16le(0)) out.add(b)
        for (b in u16le(0)) out.add(b)
        for (b in u16le(frame.width)) out.add(b)
        for (b in u16le(frame.height)) out.add(b)
        out.add(0) // no local color table, no interlace
        val data = lzwEncode(minCodeSize, mapToPalette(frame.rgba, palette))
        out.add(minCodeSize.toByte())
        var i = 0
        while (i < data.size) { // sub-blocked every 255 bytes
            val chunk = if (i + 255 <= data.size) data.copyOfRange(i, i + 255) else data.copyOfRange(i, data.size)
            out.add(chunk.size.toByte())
            for (b in chunk) out.add(b)
            i += 255
        }
        out.add(0) // block terminator
    }
    out.add(0x3b) // trailer
    return out.toByteArray()
}

// Usage: encodeGif(frames) -> GIF89a bytes — one shared palette, one image block per frame.

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 →