Video to GIF Converter — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure GIF89a encoder — quantize RGBA frames to a shared palette, LZW-compress,
// assemble the byte stream. No React, no DOM. Deterministic.
import { extractPalette, type Swatch } from './palette-extract';
export interface GifFrameInput {
width: number;
height: number;
/** RGBA, 4 bytes per pixel, top-left origin. */
rgba: Uint8Array;
/** Frame delay in milliseconds (stored as centiseconds). */
delayMs: number;
}
export interface GifEncodeOptions {
/** Palette size ceiling. @default 128 */
maxColors?: number;
}
// ---------------------------------------------------------------------------
// LZW compression (GIF variant)
// ---------------------------------------------------------------------------
/** Pack codes LSB-first at the current width, sub-blocked every 255 bytes. */
export function lzwEncode(minCodeSize: number, indices: Uint8Array): number[] {
const clearCode = 1 << minCodeSize;
const eoiCode = clearCode + 1;
let codeSize = minCodeSize + 1;
let nextCode = eoiCode + 1;
// Dictionary: (prefixCode, byte) -> code, keyed numerically.
const dict = new Map<number, number>();
const resetDict = () => {
dict.clear();
nextCode = eoiCode + 1;
codeSize = minCodeSize + 1;
};
resetDict();
const out: number[] = [];
let bitBuffer = 0;
let bitCount = 0;
const emit = (code: number) => {
bitBuffer |= code << bitCount;
bitCount += codeSize;
while (bitCount >= 8) {
out.push(bitBuffer & 0xff);
bitBuffer >>= 8;
bitCount -= 8;
}
};
const growIfDue = () => {
// 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
// width grows one entry later: right after adding code 2^codeSize.
if (nextCode - 1 === 1 << codeSize && codeSize < 12) codeSize++;
};
emit(clearCode);
if (indices.length === 0) {
emit(eoiCode);
flush();
return out;
}
let w = indices[0];
for (let i = 1; i < indices.length; i++) {
const c = indices[i];
const key = (w << 8) | c;
const found = dict.get(key);
if (found !== undefined) {
w = found;
continue;
}
emit(w);
dict.set(key, nextCode++);
growIfDue();
w = c;
if (nextCode >= 4096) {
// Dictionary full — reset like encoders do.
emit(clearCode);
resetDict();
w = c;
}
}
emit(w);
emit(eoiCode);
flush();
return out;
function flush(): void {
if (bitCount > 0) out.push(bitBuffer & 0xff);
}
}
// ---------------------------------------------------------------------------
// Palette mapping
// ---------------------------------------------------------------------------
/** Map RGBA to palette indices via exact cache + nearest RGB distance. */
function mapToPalette(rgba: Uint8Array, palette: Swatch[]): Uint8Array {
const cache = new Map<number, number>();
const indices = new Uint8Array(Math.floor(rgba.length / 4));
for (let i = 0; i < indices.length; i++) {
const o = i * 4;
const key = (rgba[o] << 16) | (rgba[o + 1] << 8) | rgba[o + 2];
let idx = cache.get(key);
if (idx === undefined) {
let best = 0;
let bestDist = Infinity;
for (let p = 0; p < palette.length; p++) {
const dr = rgba[o] - palette[p].r;
const dg = rgba[o + 1] - palette[p].g;
const db = rgba[o + 2] - palette[p].b;
const dist = dr * dr + dg * dg + db * db;
if (dist < bestDist) {
bestDist = dist;
best = p;
}
}
idx = best;
cache.set(key, idx);
}
indices[i] = idx;
}
return indices;
}
// ---------------------------------------------------------------------------
// Byte assembly
// ---------------------------------------------------------------------------
const pushAscii = (out: number[], text: string) => {
for (const ch of text) out.push(ch.charCodeAt(0));
};
const u16le = (n: number) => [n & 0xff, (n >> 8) & 0xff];
export function encodeGif(frames: GifFrameInput[], opts: GifEncodeOptions = {}): Uint8Array {
const maxColors = Math.min(opts.maxColors ?? 128, 256);
if (frames.length === 0) return new Uint8Array(0);
// One shared palette, quantized from a down-sampled mix of all frames.
const sampleTargets = frames.map((f) => {
const total = Math.floor(f.rgba.length / 4);
const stride = Math.max(1, Math.floor(total / 4096));
const out = new Uint8Array(Math.ceil(total / stride) * 4);
let o = 0;
for (let i = 0; i < total; i += stride) {
out[o++] = f.rgba[i * 4];
out[o++] = f.rgba[i * 4 + 1];
out[o++] = f.rgba[i * 4 + 2];
out[o++] = f.rgba[i * 4 + 3];
}
return out.subarray(0, o);
});
const mixed = new Uint8Array(sampleTargets.reduce((s, a) => s + a.length, 0));
let mo = 0;
for (const part of sampleTargets) {
mixed.set(part, mo);
mo += part.length;
}
const palette = extractPalette(mixed, maxColors);
if (palette.length === 0) return new Uint8Array(0);
// Palette table padded to a power of two (min 2 entries).
let tableBits = 1;
while (1 << tableBits < palette.length) tableBits++;
const tableSize = 1 << tableBits;
const out: number[] = [];
pushAscii(out, 'GIF89a');
const width = frames[0].width;
const height = frames[0].height;
out.push(...u16le(width), ...u16le(height));
out.push(0x80 | (tableBits - 1), 0, 0); // GCT flag + size; bg; aspect
for (let i = 0; i < tableSize; i++) {
const sw = palette[i];
out.push(sw ? sw.r : 0, sw ? sw.g : 0, sw ? sw.b : 0);
}
// NETSCAPE loop forever.
out.push(0x21, 0xff, 0x0b);
pushAscii(out, 'NETSCAPE2.0');
out.push(0x03, 0x01, 0x00, 0x00, 0x00);
const minCodeSize = Math.max(2, tableBits);
for (const frame of frames) {
// Graphic control extension: delay in centiseconds, no transparency.
const cs = Math.max(0, Math.min(0xffff, Math.round(frame.delayMs / 10)));
out.push(0x21, 0xf9, 0x04, 0x00, ...u16le(cs), 0x00, 0x00);
out.push(0x2c);
out.push(...u16le(0), ...u16le(0), ...u16le(frame.width), ...u16le(frame.height));
out.push(0x00); // no LCT, no interlace
const indices = mapToPalette(frame.rgba, palette);
const data = lzwEncode(minCodeSize, indices);
out.push(minCodeSize);
for (let i = 0; i < data.length; i += 255) {
const chunk = data.slice(i, i + 255);
out.push(chunk.length, ...chunk);
}
out.push(0x00); // block terminator
}
out.push(0x3b); // trailer
return Uint8Array.from(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 →