Video to GIF Converter — Ruby 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 Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# Video to GIF Converter — Ruby (3.1+), standard library only.
#
# Pure GIF89a encoder core, ported from the canonical TypeScript lib
# src/lib/gif-encode.ts (same contract as the c.c port in this directory):
# 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 arrays (4 bytes/pixel, top-left
# origin). Deterministic. Display source — part of CosmoDev's polyglot pages.
Swatch = Struct.new(:r, :g, :b, :population)
# One input frame. +delay_ms+ is stored in the stream as centiseconds.
GifFrame = Struct.new(:width, :height, :rgba, :delay_ms)
MAX_SAMPLES = 16_384 # palette down-sample ceiling (TS: palette-extract)
# ---------------------------------------------------------------------------
# Median-cut palette extraction (mirrors src/lib/palette-extract.ts)
# ---------------------------------------------------------------------------
def channel_range(bucket)
mins = [255, 255, 255]
maxs = [0, 0, 0]
bucket.each do |px|
3.times do |c|
mins[c] = px[c] if px[c] < mins[c]
maxs[c] = px[c] if px[c] > maxs[c]
end
end
spreads = 3.times.map { |c| maxs[c] - mins[c] }
best = spreads.each_with_index.max_by { |(range, _c)| range }
best[0] == 0 ? nil : best
end
def extract_palette(rgba, max_colors)
total = rgba.length / 4
return [] if total.zero?
pixels = []
stride = [1, total / MAX_SAMPLES].max
(0...total).step(stride) do |i|
o = i * 4
next if rgba[o + 3].zero? # fully transparent — skip
pixels << [rgba[o], rgba[o + 1], rgba[o + 2]]
end
return [] if pixels.empty?
buckets = [pixels]
while buckets.length < max_colors
best_idx = nil
best_range = 1 # range 1 (exact duplicates) never splits
buckets.each_with_index do |bucket, i|
range, = channel_range(bucket)
if range && range > best_range
best_range = range
best_idx = i
end
end
break unless best_idx # every bucket is uniform — done
bucket = buckets.delete_at(best_idx)
_, channel = channel_range(bucket)
# sort_by is stable in Ruby, as TS sort() is with a tie-free comparator.
sorted = bucket.sort_by { |px| px[channel] }
mid = sorted.length / 2
buckets << sorted[0, mid]
buckets << sorted[mid..]
end
buckets.filter_map do |b|
next if b.empty?
n = b.length
avg = 3.times.map { |c| (b.sum { |px| px[c] } + n / 2) / n }
Swatch.new(avg[0], avg[1], avg[2], n)
end.sort_by { |s| -s.population } # populous colors get low indices
end
# ---------------------------------------------------------------------------
# Palette mapping — exact-match cache, else nearest RGB (squared distance)
# ---------------------------------------------------------------------------
def map_to_palette(rgba, palette)
total = rgba.length / 4
indices = Array.new(total)
cache = {}
total.times do |i|
o = i * 4
key = (rgba[o] << 16) | (rgba[o + 1] << 8) | rgba[o + 2]
idx = cache[key]
unless idx
best = 0
best_dist = nil
palette.each_with_index do |sw, p|
dr = rgba[o] - sw.r
dg = rgba[o + 1] - sw.g
db = rgba[o + 2] - sw.b
dist = dr * dr + dg * dg + db * db
if best_dist.nil? || dist < best_dist
best_dist = dist
best = p
end
end
idx = cache[key] = best
end
indices[i] = idx
end
indices
end
# ---------------------------------------------------------------------------
# LZW compression (GIF variant) — codes packed LSB-first, sub-blocked later
# ---------------------------------------------------------------------------
def lzw_encode(min_code_size, indices)
clear_code = 1 << min_code_size
eoi_code = clear_code + 1
code_size = min_code_size + 1
next_code = eoi_code + 1
# Dictionary: (prefixCode, byte) -> code, keyed numerically (TS: Map).
dict = {}
reset_dict = -> {
dict.clear
next_code = eoi_code + 1
code_size = min_code_size + 1
}
out = []
bit_buffer = 0
bit_count = 0
emit = ->(code) {
bit_buffer |= code << bit_count
bit_count += code_size
while bit_count >= 8
out << (bit_buffer & 0xff)
bit_buffer >>= 8
bit_count -= 8
end
}
# The encoder's dictionary runs one entry AHEAD of the decoder's, so the
# width grows one entry later: right after adding code 2^code_size.
grow_if_due = -> {
code_size += 1 if next_code - 1 == (1 << code_size) && code_size < 12
}
emit.call(clear_code)
return out if indices.empty?
w = indices[0]
(1...indices.length).each do |i|
c = indices[i]
key = (w << 8) | c
found = dict[key]
if found
w = found
next
end
emit.call(w)
dict[key] = next_code
next_code += 1
grow_if_due.call
w = c
next if next_code < 4096
# Dictionary full — reset like encoders do (clear BEFORE continuing).
emit.call(clear_code)
reset_dict.call
w = c
end
emit.call(w)
emit.call(eoi_code)
out << (bit_buffer & 0xff) if bit_count.positive?
out
end
# ---------------------------------------------------------------------------
# Byte assembly
# ---------------------------------------------------------------------------
def u16le(n) = [n & 0xff, (n >> 8) & 0xff]
# Returns the GIF89a byte stream as a binary String. Empty input -> "".
def encode_gif(frames, max_colors: 128)
return +'' if frames.empty?
# One shared palette, quantized from a down-sampled mix of all frames.
mixed = []
frames.each do |f|
total = f.rgba.length / 4
stride = [1, total / 4096].max
(0...total).step(stride) do |i|
o = i * 4
mixed.concat(f.rgba[o, 4])
end
end
palette = extract_palette(mixed, [max_colors, 256].min)
return +'' if palette.empty?
# Palette table padded to a power of two (min 2 entries); the GCT size
# field stores bits-1, so a 2-color table is written as 0.
table_bits = 1
table_bits += 1 while (1 << table_bits) < palette.length
out = []
out.concat('GIF89a'.bytes)
width = frames.first.width
height = frames.first.height
out.concat(u16le(width), u16le(height))
out << (0x80 | (table_bits - 1)) << 0 << 0 # GCT flag + size; bg; aspect
(1 << table_bits).times do |i|
sw = palette[i]
out << (sw ? sw.r : 0) << (sw ? sw.g : 0) << (sw ? sw.b : 0)
end
# NETSCAPE loop forever.
out.concat([0x21, 0xff, 0x0b])
out.concat('NETSCAPE2.0'.bytes)
out.concat([0x03, 0x01, 0x00, 0x00, 0x00])
min_code_size = [table_bits, 2].max # GIF spec floor: 2
frames.each do |frame|
# Graphic control extension: delay in centiseconds, no transparency.
cs = (frame.delay_ms / 10.0).round.clamp(0, 0xffff)
out.concat([0x21, 0xf9, 0x04, 0x00])
out.concat(u16le(cs))
out.concat([0x00, 0x00])
out << 0x2c
out.concat(u16le(0), u16le(0), u16le(frame.width), u16le(frame.height))
out << 0x00 # no local color table, no interlace
indices = map_to_palette(frame.rgba, palette)
data = lzw_encode(min_code_size, indices)
out << min_code_size
data.each_slice(255) { |chunk| out << chunk.length; out.concat(chunk) }
out << 0x00 # block terminator
end
out << 0x3b # trailer
out.pack('C*')
end
if __FILE__ == $PROGRAM_NAME
# Smoke: two frames of a red/blue gradient — decode header fields, sizes.
frame = ->(delay) {
rgba = []
4.times { |y| 4.times { |x| rgba.concat(y < 2 ? [200, 40, 40, 255] : [30, 40, 210, 255]) } }
GifFrame.new(4, 4, rgba, delay)
}
gif = encode_gif([frame.call(100), frame.call(250)])
bytes = gif.bytes
puts "encoded #{bytes.length} bytes"
puts "header: #{gif[0, 6]} trailer: 0x%02X" % bytes.last
puts "logical screen: %dx%d" % [bytes[6] | (bytes[7] << 8), bytes[8] | (bytes[9] << 8)]
end
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 →