Video to GIF Converter — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package gifencode is the Go twin of CosmoDev's src/lib/gif-encode.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in
// lock-step). Pure + deterministic, never panics. The tests in
// video-to-gif_test.go share vectors with src/lib/gif-encode.test.ts so the
// two implementations are held to the same contract.
//
// Assembly mirrors the TS lib exactly: one shared palette quantized by
// median-cut (inlined from src/lib/palette-extract.ts, the same way the
// palette twin ports it — no cross-package import) over a strided sample of
// every frame → GCT padded to a power of two → NETSCAPE2.0 loop-forever →
// per frame a GCE with centisecond delays and an LZW image block, sub-blocked
// at 255 bytes.
package gifencode
import (
"math"
"sort"
)
// FrameInput is one RGBA frame to encode (mirrors GifFrameInput).
type FrameInput struct {
Width int
Height int
Rgba []byte // 4 bytes per pixel, top-left origin
DelayMs int // stored as centiseconds
}
// defaultMaxColors mirrors the TS default (opts.maxColors ?? 128).
const defaultMaxColors = 128
// ---------------------------------------------------------------------------
// LZW compression (GIF variant)
// ---------------------------------------------------------------------------
// LzwEncode GIF-LZW-compresses index bytes (mirrors lzwEncode in
// gif-encode.ts): codes packed LSB-first at the current width. The encoder's
// dictionary runs one entry AHEAD of the decoder's (its add for (w,c) is only
// constructible on the decoder's NEXT read), so the code width grows one entry
// later — right after adding code 2^codeSize. At 4096 codes the dictionary
// resets mid-stream with a clear code, like other encoders do.
func LzwEncode(minCodeSize int, indices []byte) []byte {
clearCode := 1 << minCodeSize
eoiCode := clearCode + 1
codeSize := minCodeSize + 1
nextCode := eoiCode + 1
// Dictionary: (prefixCode, byte) -> code, keyed numerically.
dict := make(map[int]int)
resetDict := func() {
dict = make(map[int]int)
nextCode = eoiCode + 1
codeSize = minCodeSize + 1
}
resetDict()
out := make([]byte, 0, len(indices)/2+8)
var bitBuffer uint32
var bitCount int
emit := func(code int) {
bitBuffer |= uint32(code) << uint(bitCount)
bitCount += codeSize
for bitCount >= 8 {
out = append(out, byte(bitBuffer&0xff))
bitBuffer >>= 8
bitCount -= 8
}
}
growIfDue := func() {
// Grow AFTER adding code 2^codeSize — one entry later than the decoder.
if nextCode-1 == 1<<codeSize && codeSize < 12 {
codeSize++
}
}
emit(clearCode)
if len(indices) == 0 {
emit(eoiCode)
if bitCount > 0 {
out = append(out, byte(bitBuffer&0xff))
}
return out
}
w := int(indices[0])
for i := 1; i < len(indices); i++ {
c := int(indices[i])
key := (w << 8) | c
if found, ok := dict[key]; ok {
w = found
continue
}
emit(w)
dict[key] = nextCode
nextCode++
growIfDue()
w = c
if nextCode >= 4096 {
// Dictionary full — reset like encoders do.
emit(clearCode)
resetDict()
w = c
}
}
emit(w)
emit(eoiCode)
if bitCount > 0 {
out = append(out, byte(bitBuffer&0xff))
}
return out
}
// ---------------------------------------------------------------------------
// Palette extraction (median-cut, inlined from src/lib/palette-extract.ts)
// ---------------------------------------------------------------------------
// swatch mirrors palette-extract.ts Swatch.
type swatch struct {
r, g, b int
population int
}
type rgbPixel struct{ r, g, b int }
// paletteMaxSamples mirrors MAX_SAMPLES: down-sample so large inputs
// quantize in bounded time.
const paletteMaxSamples = 16384
// extractPalette is median-cut quantization: split the widest-range bucket at
// the median of that channel, repeat. Stable ordering only — deterministic.
func extractPalette(rgba []byte, maxColors int) []swatch {
total := len(rgba) / 4
if total == 0 {
return nil
}
pixels := make([]rgbPixel, 0, total)
stride := max(total/paletteMaxSamples, 1)
for i := 0; i < total; i += stride {
o := i * 4
if rgba[o+3] == 0 {
continue // fully transparent samples are skipped
}
pixels = append(pixels, rgbPixel{int(rgba[o]), int(rgba[o+1]), int(rgba[o+2])})
}
if len(pixels) == 0 {
return nil
}
buckets := [][]rgbPixel{pixels}
for len(buckets) < maxColors {
// Widest-range bucket with more than one distinct value wins the split.
bestIdx := -1
bestRange := 1 // Range 1 (exact duplicates only) never splits further.
for i := range buckets {
if rng := channelRange(buckets[i]); rng > bestRange {
bestRange = rng
bestIdx = i
}
}
if bestIdx == -1 {
break
}
bucket := buckets[bestIdx]
buckets = append(buckets[:bestIdx], buckets[bestIdx+1:]...)
left, right := splitBucket(bucket)
buckets = append(buckets, left, right)
}
out := make([]swatch, 0, len(buckets))
for _, bucket := range buckets {
if len(bucket) == 0 {
continue
}
var sr, sg, sb int
for _, p := range bucket {
sr += p.r
sg += p.g
sb += p.b
}
// Math.round of a non-negative mean, in integer arithmetic.
n := len(bucket)
out = append(out, swatch{
r: (sr + n/2) / n,
g: (sg + n/2) / n,
b: (sb + n/2) / n,
population: n,
})
}
// Population-descending, stable (ties keep bucket order) — like the TS
// stable sort.
sort.SliceStable(out, func(i, j int) bool { return out[i].population > out[j].population })
return out
}
func channelRange(bucket []rgbPixel) int {
minR, maxR := 255, 0
minG, maxG := 255, 0
minB, maxB := 255, 0
for _, p := range 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 max(max(maxR-minR, maxG-minG), maxB-minB)
}
func splitBucket(bucket []rgbPixel) (left, right []rgbPixel) {
minR, maxR := 255, 0
minG, maxG := 255, 0
minB, maxB := 255, 0
for _, p := range 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
}
}
// Channel with the widest range; r wins ties, then g (strict >, TS order).
ch := 0 // 0=r 1=g 2=b
best := maxR - minR
if maxG-minG > best {
ch = 1
best = maxG - minG
}
if maxB-minB > best {
ch = 2
}
sorted := make([]rgbPixel, len(bucket))
copy(sorted, bucket)
sort.SliceStable(sorted, func(i, j int) bool {
switch ch {
case 1:
return sorted[i].g < sorted[j].g
case 2:
return sorted[i].b < sorted[j].b
default:
return sorted[i].r < sorted[j].r
}
})
mid := len(sorted) / 2
return sorted[:mid], sorted[mid:]
}
// ---------------------------------------------------------------------------
// Palette mapping
// ---------------------------------------------------------------------------
// mapToPalette maps RGBA to palette indices via an exact-color cache plus
// nearest RGB squared distance (mirrors mapToPalette in gif-encode.ts).
func mapToPalette(rgba []byte, palette []swatch) []byte {
indices := make([]byte, len(rgba)/4)
cache := make(map[int]int, 256)
for i := range indices {
o := i * 4
key := int(rgba[o])<<16 | int(rgba[o+1])<<8 | int(rgba[o+2])
idx, ok := cache[key]
if !ok {
best := 0
bestDist := int(^uint(0) >> 1) // max int
for p := range palette {
dr := int(rgba[o]) - palette[p].r
dg := int(rgba[o+1]) - palette[p].g
db := int(rgba[o+2]) - palette[p].b
dist := dr*dr + dg*dg + db*db
if dist < bestDist {
bestDist = dist
best = p
}
}
idx = best
cache[key] = idx
}
indices[i] = byte(idx)
}
return indices
}
// ---------------------------------------------------------------------------
// Byte assembly
// ---------------------------------------------------------------------------
func appendU16LE(out []byte, n int) []byte {
return append(out, byte(n&0xff), byte((n>>8)&0xff))
}
// EncodeGif quantizes frames onto one shared palette and assembles the GIF89a
// byte stream (mirrors encodeGif in gif-encode.ts). maxColors <= 0 means the
// default 128; values above 256 clamp to 256. No frames or no quantizable
// pixels (e.g. a zero-pixel frame) yield an empty result.
func EncodeGif(frames []FrameInput, maxColors int) []byte {
if maxColors <= 0 {
maxColors = defaultMaxColors
}
if maxColors > 256 {
maxColors = 256
}
if len(frames) == 0 {
return []byte{}
}
// One shared palette, quantized from a down-sampled mix of all frames.
mixed := make([]byte, 0, 64)
for _, f := range frames {
total := len(f.Rgba) / 4
stride := max(total/4096, 1)
for i := 0; i < total; i += stride {
o := i * 4
mixed = append(mixed, f.Rgba[o], f.Rgba[o+1], f.Rgba[o+2], f.Rgba[o+3])
}
}
palette := extractPalette(mixed, maxColors)
if len(palette) == 0 {
return []byte{}
}
// Palette table padded to a power of two (min 2 entries).
tableBits := 1
for 1<<tableBits < len(palette) {
tableBits++
}
tableSize := 1 << tableBits
out := make([]byte, 0, 1024)
out = append(out, "GIF89a"...)
out = appendU16LE(out, frames[0].Width)
out = appendU16LE(out, frames[0].Height)
out = append(out, byte(0x80|(tableBits-1)), 0, 0) // GCT flag + size; bg; aspect
for i := range tableSize {
if i < len(palette) {
out = append(out, byte(palette[i].r), byte(palette[i].g), byte(palette[i].b))
} else {
out = append(out, 0, 0, 0)
}
}
// NETSCAPE loop forever.
out = append(out, 0x21, 0xff, 0x0b)
out = append(out, "NETSCAPE2.0"...)
out = append(out, 0x03, 0x01, 0x00, 0x00, 0x00)
minCodeSize := max(tableBits, 2)
for _, frame := range frames {
// Graphic control extension: delay in centiseconds, no transparency.
cs := min(max(int(math.Round(float64(frame.DelayMs)/10)), 0), 0xffff)
out = append(out, 0x21, 0xf9, 0x04, 0x00)
out = appendU16LE(out, cs)
out = append(out, 0x00, 0x00)
out = append(out, 0x2c)
out = appendU16LE(out, 0)
out = appendU16LE(out, 0)
out = appendU16LE(out, frame.Width)
out = appendU16LE(out, frame.Height)
out = append(out, 0x00) // no LCT, no interlace
indices := mapToPalette(frame.Rgba, palette)
data := LzwEncode(minCodeSize, indices)
out = append(out, byte(minCodeSize))
for i := 0; i < len(data); i += 255 {
end := min(i+255, len(data))
chunk := data[i:end]
out = append(out, byte(len(chunk)))
out = append(out, chunk...)
}
out = append(out, 0x00) // block terminator
}
out = append(out, 0x3b) // trailer
return out
}
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 →