Video to GIF Converter — C++ 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 C++ implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Video to GIF Converter — C++20 (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.
#include <algorithm>
#include <cstdint>
#include <span>
#include <unordered_map>
#include <vector>
namespace gif_encode {
struct Pixel { std::uint8_t r, g, b; };
struct Swatch { std::uint8_t r, g, b; std::uint32_t population; };
struct GifFrameInput {
int width = 0;
int height = 0;
std::span<const std::uint8_t> rgba; // RGBA, 4 bytes/pixel, top-left origin
int delayMs = 0; // frame delay in ms (stored as centiseconds)
};
// ------------------------------------------------------ palette (median cut) --
// Per-channel spread of a pixel bucket.
struct Spread { int r, g, b; };
Spread bounds(const std::vector<Pixel>& bucket) {
int mn[3] = {255, 255, 255}, mx[3] = {0, 0, 0};
for (const Pixel& p : bucket) {
const int v[3] = {p.r, p.g, p.b};
for (int c = 0; c < 3; c++) {
if (v[c] < mn[c]) mn[c] = v[c];
if (v[c] > mx[c]) mx[c] = v[c];
}
}
return Spread{mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]};
}
// Split a bucket at the median of its widest channel (ties prefer r, then g —
// mirroring the TS reduce). std::stable_sort keeps equal pixels in sampling
// order, matching TS sort(), so the split stays deterministic.
std::vector<std::vector<Pixel>> splitBucket(std::vector<Pixel> bucket) {
Spread s = bounds(bucket);
int channel = s.g > s.r ? 1 : 0;
if (s.b > (channel == 0 ? s.r : s.g)) channel = 2;
std::stable_sort(bucket.begin(), bucket.end(), [channel](const Pixel& a, const Pixel& b) {
const int x[3] = {a.r, a.g, a.b}, y[3] = {b.r, b.g, b.b};
return x[channel] < y[channel];
});
size_t mid = bucket.size() / 2;
return {{bucket.begin(), bucket.begin() + mid}, {bucket.begin() + mid, bucket.end()}};
}
// Median-cut quantization: split the widest-range bucket until maxColors.
// Populous colors get low indices — sort by population, descending.
std::vector<Swatch> extractPalette(std::span<const std::uint8_t> rgba, int maxColors) {
size_t total = rgba.size() / 4;
std::vector<Pixel> pixels;
size_t stride = total / 16384; // bounded samples
if (stride < 1) stride = 1;
for (size_t i = 0; i < total; i += stride) {
const std::uint8_t* o = rgba.data() + i * 4;
if (o[3] > 0) pixels.push_back(Pixel{o[0], o[1], o[2]}); // skip transparent
}
if (pixels.empty()) return {};
std::vector<std::vector<Pixel>> buckets{std::move(pixels)};
while ((int)buckets.size() < maxColors) {
int best = -1, bestRange = 1; // range 1 (duplicates only) never splits
for (size_t i = 0; i < buckets.size(); i++) {
Spread s = bounds(buckets[i]);
int range = std::max({s.r, s.g, s.b});
if (range > bestRange) { bestRange = range; best = (int)i; }
}
if (best < 0) break;
std::vector<std::vector<Pixel>> halves = splitBucket(std::move(buckets[best]));
buckets.erase(buckets.begin() + best);
for (auto& h : halves) {
if (!h.empty()) buckets.push_back(std::move(h));
}
}
std::vector<Swatch> out;
for (const std::vector<Pixel>& b : buckets) {
if (b.empty()) continue;
std::uint64_t sum[3] = {0, 0, 0};
for (const Pixel& p : b) { sum[0] += p.r; sum[1] += p.g; sum[2] += p.b; }
// Rounded channel average, exactly like Math.round(sum / n).
out.push_back(Swatch{
std::uint8_t((sum[0] + b.size() / 2) / b.size()),
std::uint8_t((sum[1] + b.size() / 2) / b.size()),
std::uint8_t((sum[2] + b.size() / 2) / b.size()),
std::uint32_t(b.size())});
}
std::stable_sort(out.begin(), out.end(), [](const Swatch& a, const Swatch& b) {
return a.population > b.population;
});
return out;
}
// ---------------------------------------------------------- palette mapping --
// Map RGBA pixels to palette indices: exact-match cache, else nearest RGB.
// unordered_map is safe here — only lookups, never iteration, so the
// hash-order nondeterminism that usually bites C++ ports cannot.
std::vector<std::uint8_t> mapToPalette(std::span<const std::uint8_t> rgba,
const std::vector<Swatch>& palette) {
size_t total = rgba.size() / 4;
std::unordered_map<std::uint32_t, std::uint8_t> cache;
std::vector<std::uint8_t> indices(total);
for (size_t i = 0; i < total; i++) {
const std::uint8_t* o = rgba.data() + i * 4;
std::uint32_t key = (std::uint32_t(o[0]) << 16) | (std::uint32_t(o[1]) << 8) | o[2];
auto found = cache.find(key);
if (found != cache.end()) { indices[i] = found->second; continue; }
size_t best = 0;
long bestDist = 1L << 30;
for (size_t p = 0; p < palette.size(); p++) {
long dr = o[0] - palette[p].r, dg = o[1] - palette[p].g, db = o[2] - palette[p].b;
long dist = dr * dr + dg * dg + db * db;
if (dist < bestDist) { bestDist = dist; best = p; }
}
cache.emplace(key, std::uint8_t(best));
indices[i] = std::uint8_t(best);
}
return indices;
}
// ------------------------------------------------------------------- LZW --
// LZW compression (GIF variant): pack codes LSB-first at the current width.
// Dictionary is (prefixCode, byte) -> code, keyed numerically in a flat u32.
std::vector<std::uint8_t> lzwEncode(int minCodeSize, std::span<const std::uint8_t> indices) {
int clearCode = 1 << minCodeSize;
int eoiCode = clearCode + 1;
int codeSize = minCodeSize + 1;
int nextCode = eoiCode + 1;
std::unordered_map<std::uint32_t, int> dict;
auto resetDict = [&] {
dict.clear();
nextCode = eoiCode + 1;
codeSize = minCodeSize + 1;
};
std::vector<std::uint8_t> out;
std::uint32_t bitBuffer = 0;
int bitCount = 0;
// GIF packs codes LSB-first — the opposite bit order from PNG's deflate.
auto emit = [&](int code) {
bitBuffer |= std::uint32_t(code) << bitCount;
bitCount += codeSize;
while (bitCount >= 8) {
out.push_back(std::uint8_t(bitBuffer & 0xff));
bitBuffer >>= 8;
bitCount -= 8;
}
};
auto growIfDue = [&] {
// The encoder's dictionary runs one entry AHEAD of the decoder's (its
// add for (w,c) is only constructible on the decoder's NEXT read), so
// the width grows one entry later: right after adding code 2^codeSize.
if (nextCode - 1 == (1 << codeSize) && codeSize < 12) codeSize++;
};
emit(clearCode);
if (indices.empty()) {
emit(eoiCode);
if (bitCount > 0) out.push_back(std::uint8_t(bitBuffer & 0xff));
return out;
}
int w = indices[0];
for (size_t i = 1; i < indices.size(); i++) {
std::uint8_t c = indices[i];
std::uint32_t key = (std::uint32_t(w) << 8) | c;
auto found = dict.find(key);
if (found != dict.end()) { w = found->second; continue; }
emit(w);
dict.emplace(key, nextCode++);
growIfDue();
w = c;
if (nextCode >= 4096) {
// Dictionary full — reset like encoders do (trap: the clear code
// must be emitted at the OLD width, before resetting).
emit(clearCode);
resetDict();
w = c;
}
}
emit(w);
emit(eoiCode);
if (bitCount > 0) out.push_back(std::uint8_t(bitBuffer & 0xff));
return out;
}
// -------------------------------------------------------- byte assembly --
std::vector<std::uint8_t> encodeGif(std::span<const GifFrameInput> frames, int maxColors = 128) {
if (maxColors > 256) maxColors = 256;
if (frames.empty()) return {};
// One shared palette, quantized from a down-sampled mix of all frames.
std::vector<std::uint8_t> mixed;
for (const GifFrameInput& f : frames) {
size_t total = f.rgba.size() / 4;
size_t stride = total / 4096;
if (stride < 1) stride = 1;
for (size_t i = 0; i < total; i += stride) {
const std::uint8_t* o = f.rgba.data() + i * 4;
mixed.insert(mixed.end(), o, o + 4);
}
}
std::vector<Swatch> palette = extractPalette(mixed, maxColors);
if (palette.empty()) return {};
// 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.
int tableBits = 1;
while ((1 << tableBits) < (int)palette.size()) tableBits++;
size_t tableSize = size_t(1) << tableBits;
std::vector<std::uint8_t> out;
auto pushAscii = [&](std::string_view s) {
for (char ch : s) out.push_back(std::uint8_t(ch));
};
auto u16le = [&](int n) {
out.push_back(std::uint8_t(n & 0xff));
out.push_back(std::uint8_t((n >> 8) & 0xff));
};
pushAscii("GIF89a");
u16le(frames[0].width);
u16le(frames[0].height);
out.push_back(std::uint8_t(0x80 | (tableBits - 1))); // GCT flag + size
out.push_back(0); // background color index
out.push_back(0); // pixel aspect ratio
for (size_t i = 0; i < tableSize; i++) {
const Swatch& sw = i < palette.size() ? palette[i] : Swatch{0, 0, 0, 0};
out.push_back(sw.r);
out.push_back(sw.g);
out.push_back(sw.b);
}
// NETSCAPE loop forever.
out.push_back(0x21); out.push_back(0xff); out.push_back(0x0b);
pushAscii("NETSCAPE2.0");
out.push_back(0x03); out.push_back(0x01); out.push_back(0x00); out.push_back(0x00); out.push_back(0x00);
int minCodeSize = std::max(2, tableBits); // GIF spec floor: 2
for (const GifFrameInput& frame : frames) {
// Graphic control extension: delay in centiseconds, no transparency.
int cs = std::clamp((frame.delayMs + 5) / 10, 0, 0xffff); // rounded ms/10
out.push_back(0x21); out.push_back(0xf9); out.push_back(0x04); out.push_back(0x00);
u16le(cs);
out.push_back(0x00); out.push_back(0x00);
out.push_back(0x2c); // image separator
u16le(0); u16le(0);
u16le(frame.width);
u16le(frame.height);
out.push_back(0x00); // no local color table, no interlace
std::vector<std::uint8_t> indices = mapToPalette(frame.rgba, palette);
std::vector<std::uint8_t> data = lzwEncode(minCodeSize, indices);
out.push_back(std::uint8_t(minCodeSize));
for (size_t i = 0; i < data.size(); i += 255) { // sub-blocked every 255 bytes
size_t n = std::min<size_t>(255, data.size() - i);
out.push_back(std::uint8_t(n));
out.insert(out.end(), data.begin() + i, data.begin() + i + n);
}
out.push_back(0x00); // block terminator
}
out.push_back(0x3b); // trailer
return out;
}
} // namespace gif_encode
// Usage: encodeGif(frames, 128) -> GIF89a bytes — one shared palette,
// one image block per frame.
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 →