Skip to content

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++ (C++20), 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 std::nullopt (the TS `null` return analogue).

#include <array>
#include <cstdint>
#include <cstring>
#include <optional>
#include <vector>

#include <cstdio>

namespace gif {

struct GifFrame {
    int x = 0, y = 0, width = 0, height = 0;
    std::vector<uint8_t> palette;  // RGB triplets; empty = use the global one.
    std::vector<uint8_t> indices;  // Pixel indices in natural row order.
    int delayMs = 0, transparentIndex = -1, disposal = 0;
};

struct GifResult {
    int width = 0, height = 0;     // Logical screen size.
    std::vector<GifFrame> frames;
    std::vector<uint8_t> globalPalette;
    int loopCount = -1;            // NETSCAPE loop; 0 = forever, -1 when absent.
};

// ── GIF LZW decompression ─────────────────────────────────────────────────

// Read one code LSB-first; returns EOI on truncation, like the TS decoder.
int readCode(const std::vector<uint8_t>& data, size_t& bitPos, int codeSize, int eoiCode) {
    if ((bitPos + codeSize) >> 3 > data.size()) return eoiCode;
    int code = 0;
    for (int i = 0; i < codeSize; i++) {
        size_t byteIdx = (bitPos + i) >> 3;
        if (byteIdx >= data.size()) return eoiCode;
        code |= ((data[byteIdx] >> ((bitPos + i) & 7)) & 1) << i;
    }
    bitPos += codeSize;
    return code;
}

// Emit a code's chain; returns the chain's FIRST byte (needed for KwKwK).
uint8_t emitChain(int code, const std::array<int, 4096>& prefix,
                  const std::array<uint8_t, 4096>& suffix, std::vector<uint8_t>& out) {
    std::array<uint8_t, 4096> stack;
    int n = 0, c = code;
    while (c >= 0) { stack[n++] = suffix[c]; c = prefix[c]; }
    for (int i = n - 1; i >= 0; i--) out.push_back(stack[i]);
    return stack[n - 1];
}

// minCodeSize 2-8, clear-code resets, growing codes — same contract as TS.
std::vector<uint8_t> lzwDecode(int minCodeSize, const std::vector<uint8_t>& 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.
    std::array<int, 4096> prefix{}, first{};
    std::array<uint8_t, 4096> suffix{};
    auto resetDict = [&] {
        for (int i = 0; i < clearCode; i++) { prefix[i] = -1; suffix[i] = i; first[i] = i; }
        nextCode = eoiCode + 1; codeSize = minCodeSize + 1;
    };
    resetDict();
    std::vector<uint8_t> out;
    size_t bitPos = 0;
    int prev = -1;
    for (;;) {
        int code = readCode(data, bitPos, codeSize, eoiCode);
        if (code == eoiCode) break;
        if (code == clearCode) { resetDict(); prev = -1; continue; }
        if (prev == -1) {
            if (code >= clearCode) break; // First code after clear is a literal.
            emitChain(code, prefix, suffix, out); prev = code; continue;
        }
        if (code > nextCode) break;       // Invalid — stop like browsers do.
        // KwKwK: a code one ahead of the dictionary is prev + first(prev).
        uint8_t emittedFirst = code == nextCode
            ? (emitChain(prev, prefix, suffix, out), out.push_back(first[prev]), first[prev])
            : emitChain(code, prefix, suffix, out);
        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 out;
}

// Reorder interlaced rows into natural order; identity for short frames.
std::vector<uint8_t> deInterlace(std::vector<uint8_t> indices, int width, int height) {
    if (height < 4 || width == 0) return indices;
    std::vector<uint8_t> out(indices.size());
    size_t src = 0; // Stored pass-by-pass: four passes, starts/steps below.
    for (auto [start, step] : { std::pair<int, int>{0, 8}, {4, 8}, {2, 4}, {1, 2} }) {
        for (int row = start; row < height; row += step) {
            std::copy(indices.begin() + src, indices.begin() + src + width,
                      out.begin() + static_cast<size_t>(row) * width);
            src += width;
        }
    }
    return out;
}

// ── Whole-GIF parse ───────────────────────────────────────────────────────

int le16(const uint8_t* p) { return p[0] | (p[1] << 8); }

// Concatenate a sub-block chain; nullopt on truncation.
std::optional<std::vector<uint8_t>> readSubBlocks(const std::vector<uint8_t>& b, size_t& pos) {
    std::vector<uint8_t> out;
    for (;;) {
        if (pos >= b.size()) return std::nullopt;
        size_t size = b[pos++];
        if (size == 0) break; // Terminator ends the chain.
        if (pos + size > b.size()) return std::nullopt;
        out.insert(out.end(), b.begin() + pos, b.begin() + pos + size);
        pos += size;
    }
    return out;
}

// Parse signature, screen descriptor, extensions, and all frames.
std::optional<GifResult> decodeGif(const std::vector<uint8_t>& bytes) {
    if (bytes.size() < 13) return std::nullopt;
    if (std::memcmp(bytes.data(), "GIF87a", 6) != 0 &&
        std::memcmp(bytes.data(), "GIF89a", 6) != 0) return std::nullopt;
    // Logical screen descriptor: canvas size, flags, optional global palette.
    size_t pos = 6;
    GifResult res;
    res.width = le16(&bytes[pos]); res.height = le16(&bytes[pos + 2]);
    uint8_t packed = bytes[pos + 4]; pos += 7; // Skip bg color + aspect ratio.
    if (packed & 0x80) {
        size_t n = (2 << (packed & 7)) * 3;
        if (pos + n > bytes.size()) return std::nullopt;
        res.globalPalette.assign(bytes.begin() + pos, bytes.begin() + pos + n);
        pos += n;
    }
    int delayMs = 0, transparentIndex = -1, disposal = 0;

    for (;;) {
        if (pos >= bytes.size()) return std::nullopt;
        uint8_t block = bytes[pos++];
        if (block == 0x3b) break; // trailer
        if (block == 0x21) {      // Extension: graphic control / NETSCAPE / skip.
            if (pos >= bytes.size()) return std::nullopt;
            uint8_t label = bytes[pos++];
            if (label == 0xf9) {
                auto gce = readSubBlocks(bytes, pos);
                if (!gce || gce->size() < 4) return std::nullopt;
                disposal = ((*gce)[0] >> 2) & 7;
                delayMs = le16(&(*gce)[1]) * 10;
                transparentIndex = ((*gce)[0] & 1) ? (*gce)[3] : -1;
            } else if (label == 0xff) {
                auto app = readSubBlocks(bytes, pos);
                // Concatenated: 11-byte name, then id 1 + loop lo/hi.
                if (app && app->size() >= 14 &&
                    std::memcmp(app->data(), "NETSCAPE2.0", 11) == 0 && (*app)[11] == 1)
                    res.loopCount = le16(&(*app)[12]);
            } else if (!readSubBlocks(bytes, pos)) {
                return std::nullopt;
            }
            continue;
        }
        if (block == 0x2c) { // Image descriptor: rect, local palette, LZW data.
            if (pos + 9 > bytes.size()) return std::nullopt;
            GifFrame f;
            f.x = le16(&bytes[pos]); f.y = le16(&bytes[pos + 2]);
            f.width = le16(&bytes[pos + 4]); f.height = le16(&bytes[pos + 6]);
            uint8_t ip = bytes[pos + 8];
            pos += 9; // Descriptor is 9 bytes: x, y, w, h, packed flags.
            if (ip & 0x80) {
                size_t n = (2 << (ip & 7)) * 3;
                if (pos + n > bytes.size()) return std::nullopt;
                f.palette.assign(bytes.begin() + pos, bytes.begin() + pos + n);
                pos += n;
            }
            if (pos >= bytes.size()) return std::nullopt;
            int minCodeSize = bytes[pos++];
            // LZW payload must be a complete sub-block chain.
            auto data = readSubBlocks(bytes, pos);
            if (!data) return std::nullopt;
            f.indices = lzwDecode(minCodeSize, *data);
            if (ip & 0x40) f.indices = deInterlace(std::move(f.indices), f.width, f.height);
            f.delayMs = delayMs; f.transparentIndex = transparentIndex; f.disposal = disposal;
            res.frames.push_back(std::move(f));
            delayMs = 0; transparentIndex = -1; disposal = 0;
            continue;
        }
        return std::nullopt; // Unknown block type — bail.
    }
    return res;
}

} // namespace gif

int main() {
    // 2×1 GIF89a: 2-color global palette (red, blue), one frame, pixels [0,1].
    std::vector<uint8_t> gif = {
        'G','I','F','8','9','a', 0x02,0x00, 0x01,0x00, 0x80, 0x00, 0x00,
        0xff,0x00,0x00, 0x00,0x00,0xff,
        0x2c, 0x00,0x00, 0x00,0x00, 0x02,0x00, 0x01,0x00, 0x00,
        0x02, 0x02, 0x44,0x0a, 0x00, 0x3b
    };
    auto res = gif::decodeGif(gif);
    if (!res) { std::fprintf(stderr, "malformed GIF\n"); return 1; }
    std::printf("%dx%d, %zu frame(s), loop=%d\n",
                res->width, res->height, res->frames.size(), res->loopCount);
    for (size_t i = 0; i < res->frames.size(); i++) {
        const auto& f = res->frames[i];
        std::printf("frame %zu: %dx%d at (%d,%d), delay %dms, indices [",
                    i, f.width, f.height, f.x, f.y, f.delayMs);
        for (size_t j = 0; j < f.indices.size(); j++)
            std::printf(j ? ", %d" : "%d", f.indices[j]);
        std::printf("]\n");
    }
    return 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 →