GIF Frame Extractor — Zig 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 Zig 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: Zig (0.13+), 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.
//
// Parses GIF87a/89a, LZW-decodes every frame to indexed pixels, and
// de-interlaces interlaced rows. Caller owns every slice: pass an allocator
// in, free the result with deinit. Truncated or malformed input returns an
// error instead of panicking — like the TS lib's null returns.
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Frame = struct {
x: u16,
y: u16,
width: u16,
height: u16,
/// Palette override for this frame (RGB triplets); null = use global.
palette: ?[]u8 = null,
/// Pixel indices in natural row order (de-interlaced).
indices: []u8,
delay_ms: u16,
/// Transparent palette index; -1 when the frame has none.
transparent_index: i32,
/// Disposal method 0-7 (0/1 keep, 2 restore bg, 3 restore previous).
disposal: u3,
};
pub const Gif = struct {
width: u16,
height: u16,
frames: []Frame,
global_palette: ?[]u8,
/// NETSCAPE loop count; 0 loops forever. Absent extension → play once
/// (consumers differ on semantics — the TS lib surfaces Infinity there).
loop_count: ?u16,
pub fn deinit(self: *Gif, gpa: Allocator) void {
for (self.frames) |f| {
if (f.palette) |p| gpa.free(p);
gpa.free(f.indices);
}
gpa.free(self.frames);
if (self.global_palette) |p| gpa.free(p);
}
};
const Reader = struct {
data: []const u8,
pos: usize = 0,
fn u8At(self: *Reader) !u8 {
if (self.pos >= self.data.len) return error.Truncated;
const b = self.data[self.pos];
self.pos += 1;
return b;
}
fn u16le(self: *Reader) !u16 {
const lo = try self.u8At();
const hi = try self.u8At();
return @as(u16, lo) | (@as(u16, hi) << 8);
}
fn bytes(self: *Reader, n: usize) ![]const u8 {
if (self.pos + n > self.data.len) return error.Truncated;
const s = self.data[self.pos .. self.pos + n];
self.pos += n;
return s;
}
};
/// GIF LZW decompression — minCodeSize 2-8, clear-code resets, growing codes.
/// Dictionary as (prefix, suffix) pairs, reset on clear code; the KwKwK case
/// re-emits the previous chain's first byte, exactly like the TS emit().
pub fn lzwDecode(gpa: Allocator, min_code_size: u5, data: []const u8) ![]u8 {
if (min_code_size < 2 or min_code_size > 8) return error.BadCodeSize;
const clear_code: u16 = @as(u16, 1) << min_code_size;
const eoi_code: u16 = clear_code + 1;
var code_size: u5 = min_code_size + 1;
var next_code: u16 = eoi_code + 1;
var prefix = [_]i32{0} ** 4096;
var suffix = [_]u8{0} ** 4096;
var resetDict = struct {
fn f(p: *[4096]i32, s: *[4096]u8, cc: u16, cs: u5, nc: *u16) struct { u5, u16 } {
for (0..cc) |i| {
p[i] = -1;
s[i] = @intCast(i);
}
nc.* = cc + 2;
return .{ cs, nc.* };
}
}.f;
_ = resetDict(&prefix, &suffix, clear_code, min_code_size + 1, &next_code);
code_size = min_code_size + 1;
var out = std.ArrayList(u8).init(gpa);
errdefer out.deinit();
var stack: [4096]u8 = undefined;
var bit_pos: usize = 0;
var prev: i32 = -1;
while (true) {
// Read one codeSize-bit little-endian code; past the data = EOI.
if ((bit_pos + code_size) > data.len * 8) break;
var code: u16 = 0;
{
var i: u5 = 0;
while (i < code_size) : (i += 1) {
const idx = (bit_pos + i) >> 3;
if (idx >= data.len) break;
const bit = (data[idx] >> @intCast((bit_pos + i) & 7)) & 1;
code |= @as(u16, bit) << @intCast(i);
}
}
bit_pos += code_size;
if (code == eoi_code) break;
if (code == clear_code) {
code_size = min_code_size + 1;
next_code = eoi_code + 1;
prev = -1;
continue;
}
// Emit the code's chain via a stack unwind; remember the first byte
// (KwKwK: when the code is not yet in the dictionary, emit prev's
// chain + prev's first byte).
var chain_first: u8 = undefined;
var sp: usize = 0;
var c: i32 = @intCast(code);
if (code >= next_code) {
// KwKwK — code not yet defined: chain is prev + first(prev).
if (prev < 0) return error.BadCode;
c = prev;
chain_first = firstOf(&prefix, &suffix, prev);
} else {
chain_first = firstOf(&prefix, &suffix, c);
}
while (c >= 0) {
stack[sp] = suffix[@intCast(c)];
sp += 1;
c = prefix[@intCast(c)];
}
// Unwind in reverse = natural order.
while (sp > 0) {
sp -= 1;
try out.append(stack[sp]);
}
// Add prev+first to the dictionary (the entry the KwKwK case used).
if (prev >= 0 and next_code < 4096) {
prefix[next_code] = prev;
suffix[next_code] = chain_first;
next_code += 1;
}
prev = @intCast(code);
if (next_code == (@as(u16, 1) << code_size) and code_size < 12) code_size += 1;
}
return out.toOwnedSlice();
}
fn firstOf(prefix: *const [4096]i32, suffix: *const [4096]u8, c: i32) u8 {
var cur = c;
while (prefix[@intCast(cur)] >= 0) cur = prefix[@intCast(cur)];
return suffix[@intCast(cur)];
}
/// Decode a full GIF byte stream. Errors on bad signature or truncation.
pub fn decode(gpa: Allocator, data: []const u8) !Gif {
var r = Reader{ .data = data };
const sig = try r.bytes(6);
if (!std.mem.eql(u8, sig[0..3], "GIF")) return error.NotGif;
const width = try r.u16le();
const height = try r.u16le();
const flags = try r.u8At();
const bg = try r.u8At(); // background color index (unused, like TS)
_ = bg;
const gct_size = @as(usize, 2) << @intCast(flags & 0x07);
var global_palette: ?[]u8 = null;
if (flags & 0x80 != 0) {
const raw = try r.bytes(gct_size * 3);
global_palette = try gpa.dupe(u8, raw);
}
errdefer if (global_palette) |p| gpa.free(p);
var frames = std.ArrayList(Frame).init(gpa);
errdefer frames.deinit();
var loop_count: ?u16 = null;
// Per-frame graphic-control state, applied to the NEXT image descriptor.
var delay_ms: u16 = 0;
var transparent_index: i32 = -1;
var disposal: u3 = 0;
while (true) {
const b = r.u8At() catch break;
switch (b) {
0x3B => break, // trailer
0x21 => { // extension
const label = try r.u8At();
if (label == 0xF9) { // graphic control
const block_len = try r.u8At();
const blk = try r.bytes(block_len);
if (blk.len >= 4) {
disposal = @intCast((blk[0] >> 2) & 0x07);
delay_ms = @as(u16, blk[1]) | (@as(u16, blk[2]) << 8);
transparent_index = if (blk[0] & 1 != 0) blk[3] else -1;
}
} else if (label == 0xFF) { // application — NETSCAPE2.0 loop
const block_len = try r.u8At();
const ident = try r.bytes(block_len);
if (std.mem.eql(u8, ident, "NETSCAPE2.0")) {
// Sub-blocks: 03 01 <loop u16le>
if (try r.u8At() == 3) {
_ = try r.u8At(); // 01 sub-id
loop_count = try r.u16le();
}
}
}
// Skip any remaining sub-blocks of this extension.
while (true) {
const n = try r.u8At();
if (n == 0) break;
_ = try r.bytes(n);
}
},
0x2C => { // image descriptor
const ix = try r.u16le();
const iy = try r.u16le();
const iw = try r.u16le();
const ih = try r.u16le();
const iflags = try r.u8At();
const lct_size = @as(usize, 2) << @intCast(iflags & 0x07);
var palette: ?[]u8 = null;
if (iflags & 0x80 != 0) {
const raw = try r.bytes(lct_size * 3);
palette = try gpa.dupe(u8, raw);
}
errdefer if (palette) |p| gpa.free(p);
const min_code_size = try r.u8At();
// Concatenate the data sub-blocks, then LZW-decode.
var lzw = std.ArrayList(u8).init(gpa);
defer lzw.deinit();
while (true) {
const n = try r.u8At();
if (n == 0) break;
try lzw.appendSlice(try r.bytes(n));
}
var indices = try lzwDecode(gpa, @intCast(min_code_size), lzw.items);
errdefer gpa.free(indices);
// De-interlace: rows arrive as passes 0,8 / 4,8 / 2,4 / 1,2.
if (iflags & 0x40 != 0 and iw > 0 and ih > 0) {
const deint = try gpa.alloc(u8, @as(usize, iw) * ih);
defer gpa.free(deint);
const strides = [4][2]usize{ .{ 0, 8 }, .{ 4, 8 }, .{ 2, 4 }, .{ 1, 2 } };
var src_row: usize = 0;
for (strides) |pass| {
var y = pass[0];
while (y < ih) : (y += pass[1]) {
if (src_row < ih) {
const dst = @as(usize, y) * iw;
const src = src_row * iw;
const n = @min(iw, indices.len -| src);
@memcpy(deint[dst .. dst + n], indices[src .. src + n]);
}
src_row += 1;
}
}
@memcpy(indices[0..deint.len], deint);
}
try frames.append(.{
.x = ix,
.y = iy,
.width = iw,
.height = ih,
.palette = palette,
.indices = indices,
.delay_ms = delay_ms,
.transparent_index = transparent_index,
.disposal = disposal,
});
// Reset per-frame state (the TS lib applies it per descriptor).
delay_ms = 0;
transparent_index = -1;
disposal = 0;
},
else => return error.BadBlock,
}
}
return .{
.width = width,
.height = height,
.frames = try frames.toOwnedSlice(),
.global_palette = global_palette,
.loop_count = loop_count,
};
}
// Smoke: decode a 1×1 transparent GIF built by hand. Real vectors live in
// the TS test suite — this display port mirrors its structure.
pub fn main() !void {
var gpa_state = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa_state.deinit();
const gpa = gpa_state.allocator();
// 1x1, global 2-color table, single LZW-min-3 image of one index-0 pixel.
const one_pixel_gif = [_]u8{
'G', 'I', 'F', '8', '9', 'a', 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00,
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
0x02, 0x02, 0x44, 0x01, 0x00,
0x3B,
};
var gif = try decode(gpa, &one_pixel_gif);
defer gif.deinit(gpa);
std.debug.print("{}x{}, {} frame(s), first pixel index {d}\n", .{
gif.width, gif.height, gif.frames.len, gif.frames[0].indices[0],
});
}
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 →