GIF Frame Extractor — C# 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 C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// GIF Frame Extractor — decode a GIF byte stream into indexed frames.
//
// Language: C# (.NET 8), standard library only
// CosmoDev polyglot showcase port of the `gif-frame-extractor` tool.
// Ported from src/lib/gif-decode.ts — display source, part of CosmoDev's
// polyglot tool pages.
//
// A from-scratch GIF87a/89a parser: signature + logical screen descriptor
// (canvas size, global color table), extension blocks (frame delay,
// transparency, NETSCAPE loop count), and LZW image data decompressed to
// palette indices — interlaced frames reordered to natural row order.
// Malformed input returns null (same contract as the TS reference).
using System;
using System.Collections.Generic;
using System.Text;
namespace Gif;
/// <summary>One decoded image: a rect on the canvas plus palette-index data.</summary>
public sealed class GifFrame
{
public required int X { get; init; }
public required int Y { get; init; }
public required int Width { get; init; }
public required int Height { get; init; }
/// <summary>RGB triplets; null = use the global color table.</summary>
public byte[]? Palette { get; init; }
/// <summary>Pixel indices in natural row order.</summary>
public required byte[] Indices { get; init; }
public int DelayMs { get; init; }
public int TransparentIndex { get; init; } // -1 = opaque.
public int Disposal { get; init; }
}
/// <summary>Whole-file result: screen size, frames, palettes, loop count.</summary>
public sealed class GifResult
{
public required int Width { get; init; }
public required int Height { get; init; } // Logical screen size.
public required List<GifFrame> Frames { get; init; }
public byte[]? GlobalPalette { get; init; }
/// <summary>NETSCAPE loop; 0 = forever, null when absent.</summary>
public int? LoopCount { get; init; }
}
public static class GifFrameExtractor
{
// ── GIF LZW decompression ─────────────────────────────────────────────
/// <summary>Read one code LSB-first; returns EOI on truncation, like the TS decoder.</summary>
private static int ReadCode(byte[] data, ref int bitPos, int codeSize, int eoiCode)
{
if ((bitPos + codeSize) >> 3 > data.Length) return eoiCode;
int code = 0;
for (int i = 0; i < codeSize; i++)
{
int byteIdx = (bitPos + i) >> 3;
if (byteIdx >= data.Length) return eoiCode;
code |= ((data[byteIdx] >> ((bitPos + i) & 7)) & 1) << i;
}
bitPos += codeSize;
return code;
}
/// <summary>Emit a code's chain; returns the chain's FIRST byte (for KwKwK).</summary>
private static int EmitChain(int code, int[] prefix, int[] suffix, List<byte> output)
{
var stack = new byte[4096];
int n = 0, c = code;
while (c >= 0) { stack[n++] = (byte)suffix[c]; c = prefix[c]; }
for (int i = n - 1; i >= 0; i--) output.Add(stack[i]);
return stack[n - 1];
}
/// <summary>minCodeSize 2-8, clear-code resets, growing codes — same contract as TS.</summary>
public static byte[] LzwDecode(int minCodeSize, byte[] data)
{
int clearCode = 1 << minCodeSize, eoiCode = clearCode + 1;
int codeSize = minCodeSize + 1, nextCode = eoiCode + 1;
// Dictionary as (prefix, suffix, first-byte) triples, reset per clear.
var prefix = new int[4096];
var suffix = new int[4096];
var first = new int[4096];
var output = new List<byte>();
int bitPos = 0, prev = -1;
for (;;)
{
int code = ReadCode(data, ref bitPos, codeSize, eoiCode);
if (code == eoiCode) break;
if (code == clearCode)
{
for (int i = 0; i < clearCode; i++) { prefix[i] = -1; suffix[i] = i; first[i] = i; }
nextCode = eoiCode + 1; codeSize = minCodeSize + 1;
prev = -1;
continue;
}
if (prev == -1)
{
if (code >= clearCode) break; // First code after clear is a literal.
EmitChain(code, prefix, suffix, output);
prev = code;
continue;
}
if (code > nextCode) break; // Invalid — stop like browsers do.
// KwKwK: a code one ahead of the dictionary is prev + first(prev).
int emittedFirst;
if (code == nextCode)
{
EmitChain(prev, prefix, suffix, output);
output.Add((byte)first[prev]);
emittedFirst = first[prev];
}
else
{
emittedFirst = EmitChain(code, prefix, suffix, output);
}
if (nextCode < 4096) // Table full: TS silently no-ops this write.
{
prefix[nextCode] = prev;
suffix[nextCode] = emittedFirst;
first[nextCode] = first[prev];
nextCode++;
if (nextCode == (1 << codeSize) && codeSize < 12) codeSize++;
}
prev = code;
}
return output.ToArray();
}
// ── Interlace ──────────────────────────────────────────────────────────
/// <summary>Reorder interlaced rows into natural order; identity for short frames.</summary>
public static byte[] DeInterlace(byte[] indices, int width, int height)
{
if (height < 4 || width == 0) return indices;
var output = new byte[indices.Length];
int src = 0; // Stored pass-by-pass: four passes, starts/steps below.
int[][] passes = { new[] { 0, 8 }, new[] { 4, 8 }, new[] { 2, 4 }, new[] { 1, 2 } };
foreach (var p in passes)
{
for (int row = p[0]; row < height; row += p[1])
{
Array.Copy(indices, src, output, row * width, width);
src += width;
}
}
return output;
}
// ── Whole-GIF parse ────────────────────────────────────────────────────
private static int Le16(byte[] b, int p) => b[p] | (b[p + 1] << 8);
/// <summary>Concatenate a sub-block chain; null on truncation.</summary>
private static byte[]? ReadSubBlocks(byte[] b, ref int pos)
{
var chunks = new List<byte[]>();
int total = 0;
for (;;)
{
if (pos >= b.Length) return null;
int size = b[pos++];
if (size == 0) break; // Terminator ends the chain.
if (pos + size > b.Length) return null;
var chunk = new byte[size];
Array.Copy(b, pos, chunk, 0, size);
chunks.Add(chunk);
total += size;
pos += size;
}
var output = new byte[total];
int offset = 0;
foreach (var chunk in chunks)
{
Array.Copy(chunk, 0, output, offset, chunk.Length);
offset += chunk.Length;
}
return output;
}
/// <summary>Parse signature, screen descriptor, extensions, and all frames.</summary>
public static GifResult? DecodeGif(byte[] bytes)
{
if (bytes.Length < 13) return null;
var magic = Encoding.ASCII.GetString(bytes, 0, 6);
if (magic != "GIF87a" && magic != "GIF89a") return null;
// Logical screen descriptor: canvas size, flags, optional global palette.
int pos = 6;
int width = Le16(bytes, pos), height = Le16(bytes, pos + 2);
int packed = bytes[pos + 4];
pos += 7; // Skip bg color + aspect ratio.
byte[]? globalPalette = null;
if ((packed & 0x80) != 0)
{
int n = (2 << (packed & 7)) * 3;
if (pos + n > bytes.Length) return null;
globalPalette = new byte[n];
Array.Copy(bytes, pos, globalPalette, 0, n);
pos += n;
}
var frames = new List<GifFrame>();
int? loopCount = null; // Absent until a NETSCAPE block says otherwise.
int delayMs = 0, transparentIndex = -1, disposal = 0;
for (;;)
{
if (pos >= bytes.Length) return null;
int block = bytes[pos++];
if (block == 0x3b) break; // trailer
if (block == 0x21) // Extension: graphic control / NETSCAPE / skip.
{
if (pos >= bytes.Length) return null;
int label = bytes[pos++];
if (label == 0xf9)
{
byte[]? gce = ReadSubBlocks(bytes, ref pos);
if (gce is null || gce.Length < 4) return null;
disposal = (gce[0] >> 2) & 7;
delayMs = Le16(gce, 1) * 10;
transparentIndex = (gce[0] & 1) != 0 ? gce[3] : -1;
}
else if (label == 0xff)
{
byte[]? app = ReadSubBlocks(bytes, ref pos);
// Concatenated: 11-byte name, then id 1 + loop lo/hi.
if (app is not null && app.Length >= 14 && app[11] == 1
&& Encoding.ASCII.GetString(app, 0, 11) == "NETSCAPE2.0")
loopCount = Le16(app, 12);
}
else if (ReadSubBlocks(bytes, ref pos) is null)
{
return null;
}
continue;
}
if (block == 0x2c) // Image descriptor: rect, local palette, LZW data.
{
if (pos + 9 > bytes.Length) return null;
int x = Le16(bytes, pos), y = Le16(bytes, pos + 2);
int w = Le16(bytes, pos + 4), h = Le16(bytes, pos + 6);
int ip = bytes[pos + 8];
pos += 9; // Descriptor is 9 bytes: x, y, w, h, packed flags.
byte[]? palette = null;
if ((ip & 0x80) != 0)
{
int n = (2 << (ip & 7)) * 3;
if (pos + n > bytes.Length) return null;
palette = new byte[n];
Array.Copy(bytes, pos, palette, 0, n);
pos += n;
}
if (pos >= bytes.Length) return null;
int minCodeSize = bytes[pos++];
// LZW payload must be a complete sub-block chain.
byte[]? data = ReadSubBlocks(bytes, ref pos);
if (data is null) return null;
byte[] indices = LzwDecode(minCodeSize, data);
if ((ip & 0x40) != 0) indices = DeInterlace(indices, w, h);
frames.Add(new GifFrame
{
X = x, Y = y, Width = w, Height = h,
Palette = palette, Indices = indices,
DelayMs = delayMs, TransparentIndex = transparentIndex,
Disposal = disposal,
});
delayMs = 0; transparentIndex = -1; disposal = 0;
continue;
}
return null; // Unknown block type — bail.
}
return new GifResult
{
Width = width, Height = height, Frames = frames,
GlobalPalette = globalPalette, LoopCount = loopCount,
};
}
}
public static class GifFrameExtractorDemo
{
public static void Main()
{
// 2×1 GIF89a: 2-color global palette (red, blue), one frame, pixels [0,1].
byte[] gif =
{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a"
0x02, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, // LSD: 2×1, GCT, 2 entries
0xff, 0x00, 0x00, 0x00, 0x00, 0xff, // GCT: red, blue
0x2c, // image descriptor
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00,
0x02, // LZW min code size
0x02, 0x44, 0x0a, 0x00, // sub-block + terminator
0x3b // trailer
};
GifResult? res = GifFrameExtractor.DecodeGif(gif);
if (res is null)
{
Console.Error.WriteLine("malformed GIF");
return;
}
Console.WriteLine(
$"{res.Width}x{res.Height}, {res.Frames.Count} frame(s), loop={res.LoopCount?.ToString() ?? "∞"}");
for (int i = 0; i < res.Frames.Count; i++)
{
GifFrame f = res.Frames[i];
string idx = string.Join(", ", f.Indices);
Console.WriteLine(
$"frame {i}: {f.Width}x{f.Height} at ({f.X},{f.Y}), delay {f.DelayMs}ms, indices [{idx}]");
}
}
}
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 →