Skip to content

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 →