Video to GIF Converter — C# 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 C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Video to GIF Converter — C# (.NET 8, standard library only) port of the
// video-to-gif tool: a pure GIF89a encoder core.
// Ported from src/lib/gif-encode.ts (the canonical TypeScript implementation).
// Display source — part of CosmoDev's polyglot tool pages.
//
// Same contract as the TS reference: one palette quantized from a
// down-sampled mix of ALL frames (median cut), nearest-color mapping with an
// exact-match cache, and GIF-variant LZW. No video decode — frames arrive as
// RGBA buffers.
using System;
using System.Collections.Generic;
using System.Text;
public readonly record struct Pixel(byte R, byte G, byte B);
public readonly record struct Swatch(byte R, byte G, byte B, uint Population);
public sealed record GifFrameInput(int Width, int Height, byte[] Rgba, int DelayMs);
public static class GifEncoder
{
public static byte[] Encode(IReadOnlyList<GifFrameInput> frames, int maxColors = 256, int loopCount = 0)
{
if (frames.Count == 0) throw new ArgumentException("at least one frame required");
// Header + logical screen descriptor sized from the first frame.
var out_ = new ByteBuffer();
out_.Ascii("GIF89a");
out_.U16Le(frames[0].Width);
out_.U16Le(frames[0].Height);
out_.Push(0xF0 | 0x07); // GCT flag, 8-bit color resolution, 256-entry table
out_.Push(0); // background color index
out_.Push(0); // pixel aspect ratio — unspecified
// One palette from a down-sampled mix of ALL frames (like the TS lib).
var palette = QuantizeMixed(frames, Math.Min(maxColors, 256));
foreach (var s in palette)
{
out_.Push(s.R); out_.Push(s.G); out_.Push(s.B);
}
for (int i = palette.Count; i < 256; i++) { out_.Push(0); out_.Push(0); out_.Push(0); }
// NETSCAPE2.0 application extension — the loop-count block every
// animated GIF carries.
out_.Push(0x21); out_.Push(0xFF); out_.Push(11);
out_.Ascii("NETSCAPE2.0");
out_.Push(3); out_.Push(1); out_.U16Le(loopCount); out_.Push(0);
foreach (var f in frames) WriteFrame(out_, f, palette);
out_.Push(0x3B); // trailer
return out_.ToArray();
}
private static void WriteFrame(ByteBuffer o, GifFrameInput f, List<Swatch> palette)
{
// Graphic control extension: disposal 1 (keep), delay in 1/100 s.
o.Push(0x21); o.Push(0xF9); o.Push(4);
o.Push(0x04);
o.U16Le(Math.Max(0, f.DelayMs) / 10);
o.Push(0); // no transparent index
o.Push(0);
// Image descriptor.
o.Push(0x2C);
o.U16Le(0); o.U16Le(0); o.U16Le(f.Width); o.U16Le(f.Height);
o.Push(0); // no local color table, not interlaced
o.Push(8); // LZW minimum code size
var indices = MapToIndices(f, palette);
var lzw = LzwCompress(indices, 8);
WriteSubBlocks(o, lzw);
}
/// Nearest-color mapping with an exact-match cache — the TS lib's trick
/// for skipping the O(palette) scan on repeated colors.
private static byte[] MapToIndices(GifFrameInput f, List<Swatch> palette)
{
var cache = new Dictionary<int, byte>(); // packed RGB → index
var indices = new byte[f.Width * f.Height];
for (int i = 0; i < indices.Length; i++)
{
int o = i * 4;
int key = (f.Rgba[o] << 16) | (f.Rgba[o + 1] << 8) | f.Rgba[o + 2];
if (!cache.TryGetValue(key, out byte idx))
{
idx = Nearest(palette, f.Rgba[o], f.Rgba[o + 1], f.Rgba[o + 2]);
cache[key] = idx;
}
indices[i] = idx;
}
return indices;
}
private static byte Nearest(List<Swatch> palette, byte r, byte g, byte b)
{
byte best = 0; int bestD = int.MaxValue;
for (int i = 0; i < palette.Count; i++)
{
int dr = r - palette[i].R, dg = g - palette[i].G, db = b - palette[i].B;
int d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = (byte)i; }
}
return best;
}
/// GIF-variant LZW: variable-width codes, little-endian bit packing,
/// clear code emitted on dictionary overflow (the classic 12-bit wall).
private static byte[] LzwCompress(byte[] pixels, int minCodeSize)
{
int clear = 1 << minCodeSize;
int eoi = clear + 1;
var out_ = new ByteBuffer();
var dict = new Dictionary<long, int>();
int codeSize = minCodeSize + 1;
int next = eoi + 1;
int cur = -1;
int bits = 0, nBits = 0;
void ResetDict()
{
dict.Clear();
next = eoi + 1;
codeSize = minCodeSize + 1;
}
void Emit(int code)
{
bits |= code << nBits;
nBits += codeSize;
while (nBits >= 8) { out_.Push((byte)(bits & 0xFF)); bits >>= 8; nBits -= 8; }
}
Emit(clear);
foreach (var p in pixels)
{
if (cur < 0) { cur = p; continue; }
long key = ((long)cur << 8) | p;
if (dict.TryGetValue(key, out int code)) { cur = code; continue; }
Emit(cur);
dict[key] = next++;
if (next > (1 << codeSize) && codeSize < 12) codeSize++;
if (next == 4096) { Emit(clear); ResetDict(); }
cur = p;
}
if (cur >= 0) Emit(cur);
Emit(eoi);
if (nBits > 0) out_.Push((byte)(bits & 0xFF));
return out_.ToArray();
}
private static void WriteSubBlocks(ByteBuffer o, byte[] data)
{
for (int pos = 0; pos < data.Length; pos += 255)
{
int n = Math.Min(255, data.Length - pos);
o.Push((byte)n);
o.PushN(data, pos, n);
}
o.Push(0); // block terminator
}
/// Median-cut over a 16K-sample mix of every frame's pixels.
private static List<Swatch> QuantizeMixed(IReadOnlyList<GifFrameInput> frames, int maxColors)
{
var pixels = new List<Pixel>();
foreach (var f in frames)
{
int total = f.Rgba.Length / 4;
int stride = Math.Max(1, total / (16384 / frames.Count));
for (int i = 0; i < total; i += stride)
{
int o = i * 4;
if (f.Rgba[o + 3] > 0) pixels.Add(new Pixel(f.Rgba[o], f.Rgba[o + 1], f.Rgba[o + 2]));
}
}
if (pixels.Count == 0) pixels.Add(new Pixel(0, 0, 0));
var buckets = new List<List<Pixel>> { pixels };
while (buckets.Count < maxColors)
{
int best = -1, bestRange = 0, bestCh = 0;
for (int i = 0; i < buckets.Count; i++)
{
var (range, ch) = Spread(buckets[i]);
if (range > bestRange) { bestRange = range; best = i; bestCh = ch; }
}
if (best < 0 || bestRange == 0) break;
var b = buckets[best];
b.Sort((x, y) => bestCh == 0 ? x.R.CompareTo(y.R) : bestCh == 1 ? x.G.CompareTo(y.G) : x.B.CompareTo(y.B));
buckets.RemoveAt(best);
buckets.Add(b.GetRange(0, b.Count / 2));
buckets.Add(b.GetRange(b.Count / 2, b.Count - b.Count / 2));
}
var swatches = new List<Swatch>();
foreach (var b in buckets)
{
long r = 0, g = 0, bl = 0;
foreach (var p in b) { r += p.R; g += p.G; bl += p.B; }
swatches.Add(new Swatch((byte)Math.Round(r / (double)b.Count),
(byte)Math.Round(g / (double)b.Count),
(byte)Math.Round(bl / (double)b.Count),
(uint)b.Count));
}
return swatches;
}
private static (int range, int ch) Spread(List<Pixel> b)
{
int rMin = 255, rMax = 0, gMin = 255, gMax = 0, bMin = 255, bMax = 0;
foreach (var p in b)
{
rMin = Math.Min(rMin, p.R); rMax = Math.Max(rMax, p.R);
gMin = Math.Min(gMin, p.G); gMax = Math.Max(gMax, p.G);
bMin = Math.Min(bMin, p.B); bMax = Math.Max(bMax, p.B);
}
int r = rMax - rMin, g = gMax - gMin, bb = bMax - bMin;
if (r >= g && r >= bb) return (r, 0);
if (g >= bb) return (g, 1);
return (bb, 2);
}
}
/// Growable byte buffer — the TS version pushes to a plain number[].
internal sealed class ByteBuffer
{
private byte[] _data = new byte[256];
private int _len;
public void Push(byte v) { Ensure(1); _data[_len++] = v; }
public void PushN(byte[] src, int off, int n) { Ensure(n); Array.Copy(src, off, _data, _len, n); _len += n; }
public void Ascii(string s) { foreach (var c in Encoding.ASCII.GetBytes(s)) Push(c); }
public void U16Le(int n) { Push((byte)(n & 0xFF)); Push((byte)((n >> 8) & 0xFF)); }
public byte[] ToArray() => _data[.._len];
private void Ensure(int extra)
{
if (_len + extra > _data.Length) Array.Resize(ref _data, Math.Max(_data.Length * 2, _len + extra));
}
}
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 →