GIF Frame Extractor — Kotlin 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 Kotlin 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: Kotlin (JVM), 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).
// --- Result types ----------------------------------------------------------
/** One decoded image: a rect on the canvas plus palette-index data. */
class GifFrame(
val x: Int, val y: Int, val width: Int, val height: Int,
/** RGB triplets; null = use the global color table. */
val palette: ByteArray?,
/** Pixel indices in natural row order. */
val indices: ByteArray,
val delayMs: Int,
val transparentIndex: Int, // -1 = opaque (no transparent palette entry).
val disposal: Int,
)
/** Whole-file result: screen size, frames, palettes, loop count. */
class GifResult(
val width: Int, val height: Int, // Logical screen size.
val frames: List<GifFrame>,
val globalPalette: ByteArray?,
/** NETSCAPE loop; 0 = forever, null when absent. */
val loopCount: Int?,
)
// --- GIF LZW decompression -------------------------------------------------
/** Read one code LSB-first; returns EOI on truncation, like the TS decoder. */
private fun readCode(data: ByteArray, bitPos: IntArray, codeSize: Int, eoiCode: Int): Int {
if ((bitPos[0] + codeSize) ushr 3 > data.size) return eoiCode
var code = 0
for (i in 0 until codeSize) {
val byteIdx = (bitPos[0] + i) ushr 3
if (byteIdx >= data.size) return eoiCode
code = code or (((data[byteIdx].toInt() ushr ((bitPos[0] + i) and 7)) and 1) shl i)
}
bitPos[0] += codeSize
return code
}
/** Emit a code's chain; returns the chain's FIRST byte (needed for KwKwK). */
private fun emitChain(
code: Int, prefix: IntArray, suffix: IntArray, out: MutableList<Int>,
): Int {
val stack = IntArray(4096)
var n = 0
var c = code
while (c >= 0) { stack[n++] = suffix[c]; c = prefix[c] }
for (i in n - 1 downTo 0) out.add(stack[i])
return stack[n - 1]
}
/** minCodeSize 2-8, clear-code resets, growing codes — same contract as TS. */
fun lzwDecode(minCodeSize: Int, data: ByteArray): ByteArray {
val clearCode = 1 shl minCodeSize
val eoiCode = clearCode + 1
var codeSize = minCodeSize + 1
var nextCode = eoiCode + 1
// Dictionary as (prefix, suffix, first-byte) triples, reset per clear.
val prefix = IntArray(4096)
val suffix = IntArray(4096)
val first = IntArray(4096)
val out = mutableListOf<Int>()
val bitPos = intArrayOf(0) // One-slot array — the TS closure's mutable pos.
var prev = -1
while (true) {
val code = readCode(data, bitPos, codeSize, eoiCode)
if (code == eoiCode) break
if (code == clearCode) {
for (i in 0 until clearCode) { 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, 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).
val emittedFirst =
if (code == nextCode) {
emitChain(prev, prefix, suffix, out)
out.add(first[prev])
first[prev]
} else {
emitChain(code, prefix, suffix, 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 shl codeSize) && codeSize < 12) codeSize++
}
prev = code
}
return ByteArray(out.size) { out[it].toByte() }
}
// --- Interlace -------------------------------------------------------------
/** Reorder interlaced rows into natural order; identity for short frames. */
fun deInterlace(indices: ByteArray, width: Int, height: Int): ByteArray {
if (height < 4 || width == 0) return indices
val out = ByteArray(indices.size)
var src = 0 // Stored pass-by-pass: four passes, starts/steps below.
for ((start, step) in listOf(0 to 8, 4 to 8, 2 to 4, 1 to 2)) {
var row = start
while (row < height) {
System.arraycopy(indices, src, out, row * width, width)
src += width
row += step
}
}
return out
}
// --- Whole-GIF parse ---------------------------------------------------------
private fun le16(b: ByteArray, p: Int) = (b[p].toInt() and 0xff) or ((b[p + 1].toInt() and 0xff) shl 8)
/** Concatenate a sub-block chain; null on truncation. pos[0] advances. */
private fun readSubBlocks(b: ByteArray, pos: IntArray): ByteArray? {
val chunks = ArrayList<ByteArray>()
var total = 0
while (true) {
if (pos[0] >= b.size) return null
val size = b[pos[0]++].toInt() and 0xff
if (size == 0) break // Terminator ends the chain.
if (pos[0] + size > b.size) return null
chunks.add(b.copyOfRange(pos[0], pos[0] + size))
total += size
pos[0] += size
}
val out = ByteArray(total)
var offset = 0
for (c in chunks) { System.arraycopy(c, 0, out, offset, c.size); offset += c.size }
return out
}
/** Parse signature, screen descriptor, extensions, and all frames. */
fun decodeGif(bytes: ByteArray): GifResult? {
if (bytes.size < 13) return null
val magic = String(bytes, 0, 6, Charsets.US_ASCII)
if (magic != "GIF87a" && magic != "GIF89a") return null
// Logical screen descriptor: canvas size, flags, optional global palette.
val pos = intArrayOf(6)
val width = le16(bytes, pos[0])
val height = le16(bytes, pos[0] + 2)
val packed = bytes[pos[0] + 4].toInt() and 0xff
pos[0] += 7 // Skip bg color + aspect ratio.
var globalPalette: ByteArray? = null
if (packed and 0x80 != 0) {
val n = (2 shl (packed and 7)) * 3
if (pos[0] + n > bytes.size) return null
globalPalette = bytes.copyOfRange(pos[0], pos[0] + n)
pos[0] += n
}
val frames = ArrayList<GifFrame>()
var loopCount: Int? = null // Absent until a NETSCAPE block says otherwise.
var delayMs = 0
var transparentIndex = -1
var disposal = 0
while (true) {
if (pos[0] >= bytes.size) return null
val block = bytes[pos[0]++].toInt() and 0xff
if (block == 0x3b) break // trailer
if (block == 0x21) { // Extension: graphic control / NETSCAPE / skip.
if (pos[0] >= bytes.size) return null
when (val label = bytes[pos[0]++].toInt() and 0xff) {
0xf9 -> {
val gce = readSubBlocks(bytes, pos) ?: return null
if (gce.size < 4) return null
disposal = (gce[0].toInt() ushr 2) and 7
delayMs = le16(gce, 1) * 10
transparentIndex = if (gce[0].toInt() and 1 != 0) gce[3].toInt() and 0xff else -1
}
0xff -> {
val app = readSubBlocks(bytes, pos)
// Concatenated: 11-byte name, then id 1 + loop lo/hi.
if (app != null && app.size >= 14 && app[11].toInt() == 1 &&
String(app, 0, 11, Charsets.US_ASCII) == "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.size) return null
val x = le16(bytes, pos[0])
val y = le16(bytes, pos[0] + 2)
val w = le16(bytes, pos[0] + 4)
val h = le16(bytes, pos[0] + 6)
val ip = bytes[pos[0] + 8].toInt() and 0xff
pos[0] += 9 // Descriptor is 9 bytes: x, y, w, h, packed flags.
var palette: ByteArray? = null
if (ip and 0x80 != 0) {
val n = (2 shl (ip and 7)) * 3
if (pos[0] + n > bytes.size) return null
palette = bytes.copyOfRange(pos[0], pos[0] + n)
pos[0] += n
}
if (pos[0] >= bytes.size) return null
val minCodeSize = bytes[pos[0]++].toInt() and 0xff
// LZW payload must be a complete sub-block chain.
val data = readSubBlocks(bytes, pos) ?: return null
var indices = lzwDecode(minCodeSize, data)
if (ip and 0x40 != 0) indices = deInterlace(indices, w, h)
frames.add(GifFrame(x, y, w, h, palette, indices, delayMs, transparentIndex, disposal))
delayMs = 0; transparentIndex = -1; disposal = 0
continue
}
return null // Unknown block type — bail.
}
return GifResult(width, height, frames, globalPalette, loopCount)
}
// 2×1 GIF89a: 2-color global palette (red, blue), one frame, pixels [0,1].
fun main() {
val gif = byteArrayOf(
'G'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(),
'8'.code.toByte(), '9'.code.toByte(), 'a'.code.toByte(),
0x02, 0x00, 0x01, 0x00, 0x80.toByte(), 0x00, 0x00, // LSD: 2×1, GCT
0xff.toByte(), 0x00, 0x00, 0x00, 0x00, 0xff.toByte(), // GCT: red, blue
0x2c, // image descriptor
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00,
0x02, // LZW min code size
0x02, 0x44, 0x0a, 0x00, // sub-block + term.
0x3b // trailer
)
val res = decodeGif(gif) ?: run {
System.err.println("malformed GIF")
return
}
println("${res.width}x${res.height}, ${res.frames.size} frame(s), loop=${res.loopCount ?: "∞"}")
res.frames.forEachIndexed { i, f ->
val idx = f.indices.joinToString(", ") { (it.toInt() and 0xff).toString() }
println("frame $i: ${f.width}x${f.height} at (${f.x},${f.y}), delay ${f.delayMs}ms, indices [$idx]")
}
}
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 →