GIF Frame Extractor — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package gifdecode is the Go twin of CosmoDev's src/lib/gif-decode.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). Pure GIF87a/89a decoder: parses the byte stream and
// LZW-decodes every frame to indexed pixels. Deterministic and
// side-effect free; truncated or malformed input returns nil instead of
// panicking.
//
// The table-driven tests in gif-frame-extractor_test.go share vectors
// with src/lib/gif-decode.test.ts so the two implementations are held to
// the same contract.
package gifdecode
// GifFrame mirrors the TS GifFrame interface.
type GifFrame struct {
X, Y, Width, Height int
// Palette is the RGB-triplet override for this frame; nil = use GlobalPalette.
Palette []byte
// Indices holds the pixel indices in natural row order (de-interlaced).
Indices []byte
// DelayMs is the frame delay in milliseconds.
DelayMs int
// TransparentIndex is the transparent palette index, or -1.
TransparentIndex int
// Disposal is the disposal method 0-7 (0/1 = keep, 2 = restore bg, 3 = restore previous).
Disposal int
}
// GifResult mirrors the TS GifResult interface.
type GifResult struct {
Width, Height int
Frames []GifFrame
// GlobalPalette is the global color table (RGB triplets); nil when absent.
GlobalPalette []byte
// LoopCount mirrors the TS loopCount: 0 means loop forever. The TS lib
// uses Infinity when the NETSCAPE extension is absent (play-once
// semantics differ per consumer); Go has no integer Infinity, so absence
// is represented by LoopCountAbsent.
LoopCount int
}
// LoopCountAbsent marks a GIF with no NETSCAPE loop-count extension (TS: Infinity).
const LoopCountAbsent = -1
// LzwDecode is GIF LZW decompression — minCodeSize 2-8, clear-code resets,
// growing code width, and the KwKwK case (code one ahead of the dictionary).
// Mirrors lzwDecode() in src/lib/gif-decode.ts exactly.
func LzwDecode(minCodeSize int, data []byte) []byte {
clearCode := 1 << minCodeSize
eoiCode := clearCode + 1
codeSize := minCodeSize + 1
nextCode := eoiCode + 1
// Dictionary as (prefix, suffix, first-byte) triples — reset per clear code.
var prefix [4096]int32
var suffix, first [4096]byte
resetDict := func() {
for i := 0; i < clearCode; i++ {
prefix[i] = -1
suffix[i] = byte(i)
first[i] = byte(i)
}
nextCode = eoiCode + 1
codeSize = minCodeSize + 1
}
resetDict()
out := make([]byte, 0, len(data)*2)
var stack []byte
// emit writes a code's chain and returns the chain's FIRST byte (for KwKwK).
emit := func(code int) byte {
stack = stack[:0]
for c := int32(code); c >= 0; c = prefix[c] {
stack = append(stack, suffix[c])
}
// The stack was collected last→first; reverse it into the output.
for i := len(stack) - 1; i >= 0; i-- {
out = append(out, stack[i])
}
return stack[len(stack)-1]
}
bitPos := 0
readCode := func() int {
if (bitPos+codeSize)>>3 > len(data) {
return eoiCode
}
code := 0
for i := 0; i < codeSize; i++ {
byteIdx := (bitPos + i) >> 3
if byteIdx >= len(data) {
return eoiCode
}
bit := (data[byteIdx] >> ((bitPos + i) & 7)) & 1
code |= int(bit) << i
}
bitPos += codeSize
return code
}
prev := -1
for {
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).
var emittedFirst byte
if code == nextCode {
emit(prev)
out = append(out, first[prev])
emittedFirst = first[prev]
} else {
emittedFirst = emit(code)
}
prefix[nextCode] = int32(prev)
suffix[nextCode] = emittedFirst
first[nextCode] = first[prev]
nextCode++
prev = code
if nextCode == 1<<codeSize && codeSize < 12 {
codeSize++
}
}
return out
}
// DeInterlace reorders interlaced rows into natural order. It is the
// identity for progressive rows (height < 4 or zero width — the input
// slice is returned unchanged, like the TS lib).
// Mirrors deInterlace() in src/lib/gif-decode.ts exactly.
func DeInterlace(indices []byte, width, height int) []byte {
if height < 4 || width == 0 {
return indices
}
out := make([]byte, len(indices))
passes := []struct{ start, step int }{
{0, 8},
{4, 8},
{2, 4},
{1, 2},
}
src := 0
for _, pass := range passes {
for row := pass.start; row < height; row += pass.step {
end := src + width
if end > len(indices) {
end = len(indices) // clamp: LZW output may be short (treated as EOI)
}
copy(out[row*width:], indices[src:end])
src += width
if src > len(indices) {
return out // no stored rows left
}
}
}
return out
}
// DecodeGif parses a GIF87a/89a byte stream. It returns nil for
// truncated or malformed input (the TS lib returns null).
// Mirrors decodeGif() in src/lib/gif-decode.ts exactly: header/LSD/GCT
// parse, sub-block concatenation, NETSCAPE loop count (sub-block id 1
// after the 11-byte name), GCE (delay x10 ms, transparency flag/index,
// disposal), image descriptors with local color tables, LZW + 4-pass
// de-interlace.
func DecodeGif(bytes []byte) *GifResult {
if len(bytes) < 13 {
return nil
}
magic := string(bytes[0:6])
if magic != "GIF87a" && magic != "GIF89a" {
return nil
}
pos := 6
width := int(bytes[pos]) | int(bytes[pos+1])<<8
height := int(bytes[pos+2]) | int(bytes[pos+3])<<8
packed := bytes[pos+4]
pos += 7
var globalPalette []byte
if packed&0x80 != 0 {
entries := 2 << (packed & 7)
if pos+entries*3 > len(bytes) {
return nil
}
globalPalette = append([]byte(nil), bytes[pos:pos+entries*3]...)
pos += entries * 3
}
res := &GifResult{
Width: width,
Height: height,
GlobalPalette: globalPalette,
LoopCount: LoopCountAbsent,
}
delayMs := 0
transparentIndex := -1
disposal := 0
// readSubBlocks concatenates data sub-blocks until the 0x00 terminator.
// It returns nil when the stream ends mid-block (truncation).
readSubBlocks := func() []byte {
var chunks [][]byte
for {
if pos >= len(bytes) {
return nil
}
size := int(bytes[pos])
pos++
if size == 0 {
break
}
if pos+size > len(bytes) {
return nil
}
chunks = append(chunks, bytes[pos:pos+size])
pos += size
}
total := 0
for _, c := range chunks {
total += len(c)
}
out := make([]byte, 0, total)
for _, c := range chunks {
out = append(out, c...)
}
return out
}
loop:
for {
if pos >= len(bytes) {
return nil
}
block := bytes[pos]
pos++
switch block {
case 0x3b: // trailer
break loop
case 0x21: // Extension
if pos >= len(bytes) {
return nil
}
label := bytes[pos]
pos++
switch label {
case 0xf9: // Graphic control extension
gce := readSubBlocks()
if len(gce) < 4 {
return nil
}
disposal = int(gce[0]>>2) & 7
delayMs = (int(gce[1]) | int(gce[2])<<8) * 10
if gce[0]&1 == 1 {
transparentIndex = int(gce[3])
} else {
transparentIndex = -1
}
case 0xff: // Application extension
app := readSubBlocks()
// Sub-block contents concatenated: the 11-byte name, then id 1 + loop lo/hi.
if len(app) >= 14 && string(app[0:11]) == "NETSCAPE2.0" && app[11] == 1 {
res.LoopCount = int(app[12]) | int(app[13])<<8
}
default: // Comment / plain text / unknown — skip the sub-blocks.
if readSubBlocks() == nil {
return nil
}
}
case 0x2c: // Image descriptor
if pos+9 > len(bytes) {
return nil
}
x := int(bytes[pos]) | int(bytes[pos+1])<<8
y := int(bytes[pos+2]) | int(bytes[pos+3])<<8
w := int(bytes[pos+4]) | int(bytes[pos+5])<<8
h := int(bytes[pos+6]) | int(bytes[pos+7])<<8
ip := bytes[pos+8]
pos += 9
var palette []byte
if ip&0x80 != 0 {
entries := 2 << (ip & 7)
if pos+entries*3 > len(bytes) {
return nil
}
palette = append([]byte(nil), bytes[pos:pos+entries*3]...)
pos += entries * 3
}
if pos >= len(bytes) {
return nil
}
minCodeSize := int(bytes[pos])
pos++
data := readSubBlocks()
if data == nil {
return nil
}
indices := LzwDecode(minCodeSize, data)
final := indices
if ip&0x40 != 0 {
final = DeInterlace(indices, w, h)
}
res.Frames = append(res.Frames, GifFrame{
X: x,
Y: y,
Width: w,
Height: h,
Palette: palette,
Indices: final,
DelayMs: delayMs,
TransparentIndex: transparentIndex,
Disposal: disposal,
})
delayMs = 0
transparentIndex = -1
disposal = 0
default:
return nil // Unknown block type — bail.
}
}
return res
}
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 →