Video to GIF Converter — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Video to GIF Converter — Zig (0.13+, 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. Caller owns the returned slice.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Pixel = struct { r: u8, g: u8, b: u8 };
const Swatch = struct { r: u8, g: u8, b: u8, population: u32 };
const GifFrameInput = struct { width: u16, height: u16, rgba: []const u8, delay_ms: u16 };
/// Encode RGBA frames into an animated GIF89a byte stream.
pub fn encode(gpa: Allocator, frames: []const GifFrameInput, max_colors: usize, loop_count: u16) ![]u8 {
if (frames.len == 0) return error.NoFrames;
var out = std.ArrayList(u8).init(gpa);
errdefer out.deinit();
// Header + logical screen descriptor sized from the first frame.
try out.appendSlice("GIF89a");
try u16le(&out, frames[0].width);
try u16le(&out, frames[0].height);
try out.append(0xF0 | 0x07); // GCT flag, 8-bit color resolution, 256 entries
try out.append(0); // background color index
try out.append(0); // pixel aspect ratio — unspecified
// One palette from a down-sampled mix of ALL frames (like the TS lib).
var palette = try quantizeMixed(gpa, frames, @min(max_colors, 256));
defer palette.deinit(gpa);
for (palette.items) |s| {
try out.appendSlice(&.{ s.r, s.g, s.b });
}
var fill: usize = palette.items.len;
while (fill < 256) : (fill += 3) try out.appendSlice(&.{ 0, 0, 0 });
// NETSCAPE2.0 application extension — the loop-count block every
// animated GIF carries.
try out.appendSlice(&.{ 0x21, 0xFF, 11 });
try out.appendSlice("NETSCAPE2.0");
try out.appendSlice(&.{ 3, 1 });
try u16le(&out, loop_count);
try out.append(0);
for (frames) |f| try writeFrame(gpa, &out, f, palette.items);
try out.append(0x3B); // trailer
return out.toOwnedSlice();
}
fn writeFrame(gpa: Allocator, o: *std.ArrayList(u8), f: GifFrameInput, palette: []const Swatch) !void {
// Graphic control extension: disposal 1 (keep), delay in 1/100 s.
try o.appendSlice(&.{ 0x21, 0xF9, 4, 0x04 });
try u16le(o, f.delay_ms / 10);
try o.appendSlice(&.{ 0, 0 }); // no transparent index, block terminator
// Image descriptor.
try o.append(0x2C);
try u16le(o, 0);
try u16le(o, 0);
try u16le(o, f.width);
try u16le(o, f.height);
try o.append(0); // no local color table, not interlaced
try o.append(8); // LZW minimum code size
const indices = try mapToIndices(gpa, f, palette);
defer gpa.free(indices);
const lzw = try lzwCompress(gpa, indices, 8);
defer gpa.free(lzw);
try 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.
fn mapToIndices(gpa: Allocator, f: GifFrameInput, palette: []const Swatch) ![]u8 {
const n = @as(usize, f.width) * f.height;
const indices = try gpa.alloc(u8, n);
var cache = std.AutoHashMap(u32, u8).init(gpa);
defer cache.deinit();
for (0..n) |i| {
const o = i * 4;
const key = (@as(u32, f.rgba[o]) << 16) | (@as(u32, f.rgba[o + 1]) << 8) | f.rgba[o + 2];
const hit = cache.get(key);
if (hit) |idx| {
indices[i] = idx;
} else {
const idx = nearest(palette, f.rgba[o], f.rgba[o + 1], f.rgba[o + 2]);
try cache.put(key, idx);
indices[i] = idx;
}
}
return indices;
}
fn nearest(palette: []const Swatch, r: u8, g: u8, b: u8) u8 {
var best: u8 = 0;
var best_d: i32 = std.math.maxInt(i32);
for (palette, 0..) |s, i| {
const dr = @as(i32, r) - s.r;
const dg = @as(i32, g) - s.g;
const db = @as(i32, b) - s.b;
const d = dr * dr + dg * dg + db * db;
if (d < best_d) {
best_d = d;
best = @intCast(i);
}
}
return best;
}
/// GIF-variant LZW: variable-width codes, little-endian bit packing, clear
/// code emitted on dictionary overflow (the classic 12-bit wall).
fn lzwCompress(gpa: Allocator, pixels: []const u8, min_code_size: u5) ![]u8 {
const clear: u32 = @as(u32, 1) << min_code_size;
const eoi: u32 = clear + 1;
var out = std.ArrayList(u8).init(gpa);
errdefer out.deinit();
var dict = std.AutoHashMap(u64, u32).init(gpa);
defer dict.deinit();
var code_size: u5 = min_code_size + 1;
var next: u32 = eoi + 1;
var cur: i32 = -1;
var bits: u32 = 0;
var n_bits: u5 = 0;
const Emit = struct {
fn f(o: *std.ArrayList(u8), b: *u32, nb: *u5, code: u32, cs: u5) !void {
b.* |= code << nb.*;
nb.* += cs;
while (nb.* >= 8) {
try o.append(@truncate(b.* & 0xFF));
b.* >>= 8;
nb.* -= 8;
}
}
}.f;
try Emit(&out, &bits, &n_bits, clear, code_size);
for (pixels) |p| {
if (cur < 0) {
cur = p;
continue;
}
const key = (@as(u64, @intCast(cur)) << 8) | p;
if (dict.get(key)) |code| {
cur = @intCast(code);
continue;
}
try Emit(&out, &bits, &n_bits, @intCast(cur), code_size);
try dict.put(key, next);
next += 1;
if (next > (@as(u32, 1) << code_size) and code_size < 12) code_size += 1;
if (next == 4096) {
try Emit(&out, &bits, &n_bits, clear, code_size);
dict.clearRetainingCapacity();
next = eoi + 1;
code_size = min_code_size + 1;
}
cur = p;
}
if (cur >= 0) try Emit(&out, &bits, &n_bits, @intCast(cur), code_size);
try Emit(&out, &bits, &n_bits, eoi, code_size);
if (n_bits > 0) try out.append(@truncate(bits & 0xFF));
return out.toOwnedSlice();
}
fn writeSubBlocks(o: *std.ArrayList(u8), data: []const u8) !void {
var pos: usize = 0;
while (pos < data.len) : (pos += 255) {
const n: u8 = @intCast(@min(255, data.len - pos));
try o.append(n);
try o.appendSlice(data[pos .. pos + n]);
}
try o.append(0); // block terminator
}
const Palette = struct {
items: []Swatch,
fn deinit(self: *Palette, gpa: Allocator) void {
gpa.free(self.items);
}
};
/// Median-cut over a 16K-sample mix of every frame's pixels.
fn quantizeMixed(gpa: Allocator, frames: []const GifFrameInput, max_colors: usize) !Palette {
var pixels = std.ArrayList(Pixel).init(gpa);
defer pixels.deinit();
for (frames) |f| {
const total = f.rgba.len / 4;
const stride = @max(1, total / (16384 / frames.len));
var i: usize = 0;
while (i < total) : (i += stride) {
const o = i * 4;
if (f.rgba[o + 3] > 0)
try pixels.append(.{ .r = f.rgba[o], .g = f.rgba[o + 1], .b = f.rgba[o + 2] });
}
}
if (pixels.items.len == 0) try pixels.append(.{ .r = 0, .g = 0, .b = 0 });
var buckets = std.ArrayList([]Pixel).init(gpa);
defer buckets.deinit();
try buckets.append(pixels.items);
while (buckets.items.len < max_colors) {
var best: ?usize = null;
var best_range: u8 = 0;
var best_ch: u2 = 0;
for (buckets.items, 0..) |b, i| {
const s = spread(b);
if (s.range > best_range) {
best_range = s.range;
best = i;
best_ch = s.ch;
}
}
if (best == null or best_range == 0) break;
const b = buckets.orderedRemove(best.?);
const sorted = try gpa.dupe(Pixel, b);
// Zig sort is unstable; indexed tiebreak mirrors the TS stable sort.
const Idx = struct { px: Pixel, i: usize };
var idx = try gpa.alloc(Idx, sorted.len);
defer gpa.free(idx);
for (sorted, 0..) |p, k| idx[k] = .{ .px = p, .i = k };
const ch = best_ch;
std.sort.pdq(Idx, idx, ch, struct {
fn lessThan(channel: u2, a: Idx, b: Idx) bool {
const av = switch (channel) { 0 => a.px.r, 1 => a.px.g, else => a.px.b };
const bv = switch (channel) { 0 => b.px.r, 1 => b.px.g, else => b.px.b };
return if (av == bv) a.i < b.i else av < bv;
}
}.lessThan);
for (idx, 0..) |e, k| sorted[k] = e.px;
try buckets.append(sorted[0 .. sorted.len / 2]);
try buckets.append(sorted[sorted.len / 2 ..]);
}
const items = try gpa.alloc(Swatch, buckets.items.len);
for (buckets.items, 0..) |b, i| {
var rs: u64 = 0;
var gs: u64 = 0;
var bs: u64 = 0;
for (b) |p| {
rs += p.r;
gs += p.g;
bs += p.b;
}
items[i] = .{
.r = @intCast(@divTrunc(rs + b.len / 2, b.len)),
.g = @intCast(@divTrunc(gs + b.len / 2, b.len)),
.b = @intCast(@divTrunc(bs + b.len / 2, b.len)),
.population = @intCast(b.len),
};
}
return .{ .items = items };
}
const SpreadRC = struct { range: u8, ch: u2 };
fn spread(b: []const Pixel) SpreadRC {
var mn = [3]u8{ 255, 255, 255 };
var mx = [3]u8{ 0, 0, 0 };
for (b) |p| {
const v = [3]u8{ p.r, p.g, p.b };
for (0..3) |c| {
mn[c] = @min(mn[c], v[c]);
mx[c] = @max(mx[c], v[c]);
}
}
const r = mx[0] - mn[0];
const g = mx[1] - mn[1];
const bb = mx[2] - mn[2];
if (r >= g and r >= bb) return .{ .range = r, .ch = 0 };
if (g >= bb) return .{ .range = g, .ch = 1 };
return .{ .range = bb, .ch = 2 };
}
fn u16le(o: *std.ArrayList(u8), n: u16) !void {
try o.append(@truncate(n & 0xFF));
try o.append(@truncate(n >> 8));
}
pub fn main() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const gpa = arena.allocator();
// Smoke: a 1×1 red frame encodes to a tiny valid GIF89a.
const frame = GifFrameInput{
.width = 1,
.height = 1,
.rgba = &[_]u8{ 255, 0, 0, 255 },
.delay_ms = 100,
};
const gif = try encode(gpa, &.{frame}, 256, 0);
std.debug.print("{d} bytes, header {s}\n", .{ gif.len, gif[0..6] }); // GIF89a
}
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 →