GIF Frame Extractor — Ruby 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 Ruby 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: Ruby (3.x), 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 nil (same contract as the TS reference). Bytes
# travel as binary Strings — indexing yields code points, not characters.
module GifFrameExtractor
Frame = Struct.new(:x, :y, :width, :height,
:palette, # RGB-triplet binary String; nil = global.
:indices, # Pixel indices in natural row order.
:delay_ms, :transparent_index, :disposal)
Result = Struct.new(:width, :height, # Logical screen size.
:frames, :global_palette,
:loop_count) # 0 = forever, nil when absent.
module_function
# -- GIF LZW decompression ----------------------------------------------
# minCodeSize 2-8, clear-code resets, growing codes — same contract as TS.
def lzw_decode(min_code_size, data)
clear_code = 1 << min_code_size
eoi_code = clear_code + 1
code_size = min_code_size + 1
next_code = eoi_code + 1
# Dictionary as (prefix, suffix, first-byte) triples, reset per clear.
prefix = Array.new(4096, -1)
suffix = Array.new(4096) { |i| i < clear_code ? i : 0 }
first = prefix.dup
clear_code.times { |i| prefix[i] = -1; suffix[i] = i; first[i] = i }
out = []
bit_pos = 0
prev = -1
# Read one code LSB-first; returns EOI on truncation, like the TS decoder.
read_code = lambda do
return eoi_code if ((bit_pos + code_size) >> 3) > data.bytesize
code = 0
code_size.times do |i|
byte_idx = (bit_pos + i) >> 3
return eoi_code if byte_idx >= data.bytesize
code |= ((data.getbyte(byte_idx) >> ((bit_pos + i) & 7)) & 1) << i
end
bit_pos += code_size
code
end
# Emit a code's chain; returns the chain's FIRST byte (needed for KwKwK).
emit = lambda do |code|
stack = []
c = code
while c >= 0
stack << suffix[c]
c = prefix[c]
end
out.concat(stack.reverse)
stack.last
end
loop do
code = read_code.call
break if code == eoi_code
if code == clear_code
next_code = eoi_code + 1
code_size = min_code_size + 1
clear_code.times { |i| prefix[i] = -1; suffix[i] = i; first[i] = i }
prev = -1
next
end
if prev == -1
break if code >= clear_code # First code after clear must be a literal.
emit.call(code)
prev = code
next
end
break if code > next_code # Invalid — stop like browsers do.
# KwKwK: a code one ahead of the dictionary is prev + first(prev).
emitted_first =
if code == next_code
emit.call(prev)
out << first[prev]
first[prev]
else
emit.call(code)
end
if next_code < 4096 # Table full: TS silently no-ops this write.
prefix[next_code] = prev
suffix[next_code] = emitted_first
first[next_code] = first[prev]
next_code += 1
code_size += 1 if next_code == (1 << code_size) && code_size < 12
end
prev = code
end
out.pack('C*')
end
# -- Interlace -------------------------------------------------------------
# Reorder interlaced rows into natural order; identity for short frames.
def de_interlace(indices, width, height)
return indices if height < 4 || width.zero?
idx = indices.unpack('C*')
out = Array.new(idx.length)
src = 0 # Stored pass-by-pass: four passes, starts/steps below.
[[0, 8], [4, 8], [2, 4], [1, 2]].each do |start, step|
(start...height).step(step) do |row|
out[row * width, width] = idx[src, width]
src += width
end
end
out.pack('C*')
end
# -- Whole-GIF parse -------------------------------------------------------
# Concatenate a sub-block chain; nil on truncation.
def read_sub_blocks(bytes, pos)
chunks = []
loop do
return nil if pos >= bytes.bytesize
size = bytes.getbyte(pos)
pos += 1
break if size.zero? # Terminator ends the chain.
return nil if pos + size > bytes.bytesize
chunks << bytes.byteslice(pos, size)
pos += size
end
chunks.join
end
# Parse signature, screen descriptor, extensions, and all frames.
def decode_gif(bytes)
return nil if bytes.bytesize < 13
return nil unless bytes.byteslice(0, 6) == 'GIF87a' || bytes.byteslice(0, 6) == 'GIF89a'
# Logical screen descriptor: canvas size, flags, optional global palette.
pos = 6
width = bytes.getbyte(pos) | (bytes.getbyte(pos + 1) << 8)
height = bytes.getbyte(pos + 2) | (bytes.getbyte(pos + 3) << 8)
packed = bytes.getbyte(pos + 4)
pos += 7 # Skip bg color + aspect ratio.
global_palette = nil
if packed & 0x80 != 0
n = (2 << (packed & 7)) * 3
return nil if pos + n > bytes.bytesize
global_palette = bytes.byteslice(pos, n)
pos += n
end
frames = []
loop_count = nil # Absent until a NETSCAPE block says otherwise.
delay_ms = 0
transparent_index = -1
disposal = 0
loop do
return nil if pos >= bytes.bytesize
block = bytes.getbyte(pos)
pos += 1
break if block == 0x3b # trailer
if block == 0x21 # Extension: graphic control / NETSCAPE / skip.
return nil if pos >= bytes.bytesize
label = bytes.getbyte(pos)
pos += 1
if label == 0xf9
gce = read_sub_blocks(bytes, pos)
return nil if gce.nil? || gce.bytesize < 4
disposal = (gce.getbyte(0) >> 2) & 7
delay_ms = (gce.getbyte(1) | (gce.getbyte(2) << 8)) * 10
transparent_index = gce.getbyte(0) & 1 != 0 ? gce.getbyte(3) : -1
elsif label == 0xff
app = read_sub_blocks(bytes, pos)
# Concatenated: 11-byte name, then id 1 + loop lo/hi.
loop_count = app.getbyte(12) | (app.getbyte(13) << 8) if app &&
app.bytesize >= 14 && app.byteslice(0, 11) == 'NETSCAPE2.0' &&
app.getbyte(11) == 1
elsif read_sub_blocks(bytes, pos).nil?
return nil
end
next
end
if block == 0x2c # Image descriptor: rect, local palette, LZW data.
return nil if pos + 9 > bytes.bytesize
x = bytes.getbyte(pos) | (bytes.getbyte(pos + 1) << 8)
y = bytes.getbyte(pos + 2) | (bytes.getbyte(pos + 3) << 8)
w = bytes.getbyte(pos + 4) | (bytes.getbyte(pos + 5) << 8)
h = bytes.getbyte(pos + 6) | (bytes.getbyte(pos + 7) << 8)
ip = bytes.getbyte(pos + 8)
pos += 9 # Descriptor is 9 bytes: x, y, w, h, packed flags.
palette = nil
if ip & 0x80 != 0
n = (2 << (ip & 7)) * 3
return nil if pos + n > bytes.bytesize
palette = bytes.byteslice(pos, n)
pos += n
end
return nil if pos >= bytes.bytesize
min_code_size = bytes.getbyte(pos)
pos += 1
data = read_sub_blocks(bytes, pos) # Must be a complete sub-block chain.
return nil if data.nil?
indices = lzw_decode(min_code_size, data)
indices = de_interlace(indices, w, h) if ip & 0x40 != 0
frames << Frame.new(x, y, w, h, palette, indices,
delay_ms, transparent_index, disposal)
delay_ms = 0
transparent_index = -1
disposal = 0
next
end
return nil # Unknown block type — bail.
end
Result.new(width, height, frames, global_palette, loop_count)
end
end
# 2×1 GIF89a: 2-color global palette (red, blue), one frame, pixels [0,1].
gif = [
'GIF89a', "\x02\x00\x01\x00\x80\x00\x00",
"\xff\x00\x00", "\x00\x00\xff",
"\x2c", "\x00\x00\x00\x00\x02\x00\x01\x00\x00",
"\x02", "\x02\x44\x0a", "\x00", "\x3b"
].join.force_encoding('ASCII-8BIT')
res = GifFrameExtractor.decode_gif(gif)
abort 'malformed GIF' if res.nil?
puts "#{res.width}x#{res.height}, #{res.frames.length} frame(s), loop=#{res.loop_count}"
res.frames.each_with_index do |f, i|
idx = f.indices.unpack('C*').join(', ')
puts "frame #{i}: #{f.width}x#{f.height} at (#{f.x},#{f.y}), " \
"delay #{f.delay_ms}ms, indices [#{idx}]"
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 →