Skip to content

Video to GIF Converter — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Video to GIF Converter — Swift (5.9+, 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),
// with the palette step inlined from src/lib/palette-extract.ts.
// 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.

struct GifFrameInput {
  let width: Int
  let height: Int
  /// RGBA, 4 bytes per pixel, top-left origin.
  let rgba: [UInt8]
  /// Frame delay in milliseconds (stored as centiseconds).
  let delayMs: Int
}

struct Swatch {
  let r: Int, g: Int, b: Int
  let population: Int
}

struct Pixel {
  let r: Int, g: Int, b: Int
}

// ---------------------------------------------------------------------------
// Median-cut palette (inlined port of src/lib/palette-extract.ts)
// ---------------------------------------------------------------------------

/// Down-sample so large frames quantize in bounded time (TS: MAX_SAMPLES).
let maxSamples = 16_384

/// Median-cut quantization: split the widest channel at the median, repeat.
func extractPalette(_ rgba: [UInt8], maxColors: Int = 8) -> [Swatch] {
  let total = rgba.count / 4
  if total == 0 { return [] }

  var pixels: [Pixel] = []
  let stride = max(1, total / maxSamples)
  var i = 0
  while i < total {
    let o = i * 4
    if rgba[o + 3] > 0 { // skip transparent
      pixels.append(Pixel(r: Int(rgba[o]), g: Int(rgba[o + 1]), b: Int(rgba[o + 2])))
    }
    i += stride
  }
  if pixels.isEmpty { return [] }

  var buckets: [[Pixel]] = [pixels]
  while buckets.count < maxColors {
    // Widest-range bucket with more than one distinct value wins the split.
    var bestIdx = -1
    var bestRange = 1 // range 1 (exact duplicates only) never splits further
    for (i, bucket) in buckets.enumerated() {
      let range = channelRange(bucket)
      if range > bestRange {
        bestRange = range
        bestIdx = i
      }
    }
    if bestIdx == -1 { break }
    let halves = splitBucket(buckets.remove(at: bestIdx))
    buckets.append(contentsOf: halves)
  }

  return buckets
    .filter { !$0.isEmpty }
    .map { bucket in
      let n = Double(bucket.count)
      return Swatch(
        r: Int((Double(bucket.reduce(0) { $0 + $1.r }) / n).rounded()),
        g: Int((Double(bucket.reduce(0) { $0 + $1.g }) / n).rounded()),
        b: Int((Double(bucket.reduce(0) { $0 + $1.b }) / n).rounded()),
        population: bucket.count)
    }
    .sorted { $0.population > $1.population } // populous colors get low indices
}

private func channelRange(_ bucket: [Pixel]) -> Int {
  var minR = 255, maxR = 0, minG = 255, maxG = 0, minB = 255, maxB = 0
  for p in bucket {
    minR = min(minR, p.r); maxR = max(maxR, p.r)
    minG = min(minG, p.g); maxG = max(maxG, p.g)
    minB = min(minB, p.b); maxB = max(maxB, p.b)
  }
  return max(maxR - minR, maxG - minG, maxB - minB)
}

private func splitBucket(_ bucket: [Pixel]) -> [[Pixel]] {
  var minR = 255, maxR = 0, minG = 255, maxG = 0, minB = 255, maxB = 0
  for p in bucket {
    minR = min(minR, p.r); maxR = max(maxR, p.r)
    minG = min(minG, p.g); maxG = max(maxG, p.g)
    minB = min(minB, p.b); maxB = max(maxB, p.b)
  }
  var range = maxR - minR, channel = 0
  if maxG - minG > range { range = maxG - minG; channel = 1 }
  if maxB - minB > range { range = maxB - minB; channel = 2 }

  // enumerated().sorted keeps the split deterministic, matching the TS lib's
  // stable sort on ties.
  let sorted = bucket.enumerated()
    .sorted { a, b in
      let av = channel == 0 ? a.element.r : (channel == 1 ? a.element.g : a.element.b)
      let bv = channel == 0 ? b.element.r : (channel == 1 ? b.element.g : b.element.b)
      return av == bv ? a.offset < b.offset : av < bv
    }
    .map(\.element)
  let mid = sorted.count / 2
  return [Array(sorted[..<mid]), Array(sorted[mid...])]
}

// ---------------------------------------------------------------------------
// LZW compression (GIF variant)
// ---------------------------------------------------------------------------

/// Pack codes LSB-first at the current width, sub-blocked every 255 bytes.
func lzwEncode(_ minCodeSize: Int, _ indices: [UInt8]) -> [UInt8] {
  let clearCode = 1 << minCodeSize
  let eoiCode = clearCode + 1
  var codeSize = minCodeSize + 1
  var nextCode = eoiCode + 1

  // Dictionary: (prefixCode, byte) -> code, keyed numerically.
  var dict: [Int: Int] = [:]
  func resetDict() {
    dict.removeAll(keepingCapacity: true)
    nextCode = eoiCode + 1
    codeSize = minCodeSize + 1
  }
  resetDict()

  var out: [UInt8] = []
  var bitBuffer = 0
  var bitCount = 0
  func emit(_ code: Int) {
    // GIF packs codes LSB-first — the opposite bit order from PNG's deflate.
    bitBuffer |= code << bitCount
    bitCount += codeSize
    while bitCount >= 8 {
      out.append(UInt8(bitBuffer & 0xff))
      bitBuffer >>= 8
      bitCount -= 8
    }
  }
  func flush() {
    if bitCount > 0 { out.append(UInt8(bitBuffer & 0xff)) }
  }
  func 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 += 1 }
  }

  emit(clearCode)
  if indices.isEmpty {
    emit(eoiCode)
    flush()
    return out
  }

  var w = Int(indices[0])
  for i in 1..<indices.count {
    let c = Int(indices[i])
    let key = (w << 8) | c
    if let found = dict[key] {
      w = found
      continue
    }
    emit(w)
    dict[key] = nextCode
    nextCode += 1
    growIfDue()
    w = c
    if nextCode >= 4096 {
      // Dictionary full — reset like encoders do.
      emit(clearCode)
      resetDict()
      w = c
    }
  }
  emit(w)
  emit(eoiCode)
  flush()
  return out
}

// ---------------------------------------------------------------------------
// Palette mapping
// ---------------------------------------------------------------------------

/// Map RGBA to palette indices via exact cache + nearest RGB distance.
private func mapToPalette(_ rgba: [UInt8], _ palette: [Swatch]) -> [UInt8] {
  let count = rgba.count / 4
  var indices = [UInt8](repeating: 0, count: count)
  var cache: [Int: UInt8] = [:] // 24-bit packed RGB key
  for i in 0..<count {
    let o = i * 4
    let key = (Int(rgba[o]) << 16) | (Int(rgba[o + 1]) << 8) | Int(rgba[o + 2])
    if let idx = cache[key] {
      indices[i] = idx
      continue
    }
    var best = 0
    var bestDist = Int.max
    for (p, sw) in palette.enumerated() {
      let dr = Int(rgba[o]) - sw.r
      let dg = Int(rgba[o + 1]) - sw.g
      let db = Int(rgba[o + 2]) - sw.b
      let dist = dr * dr + dg * dg + db * db
      if dist < bestDist {
        bestDist = dist
        best = p
      }
    }
    cache[key] = UInt8(best)
    indices[i] = UInt8(best)
  }
  return indices
}

// ---------------------------------------------------------------------------
// Byte assembly
// ---------------------------------------------------------------------------

func encodeGif(_ frames: [GifFrameInput], maxColors: Int = 128) -> [UInt8] {
  let maxColors = min(maxColors, 256)
  if frames.isEmpty { return [] }

  // One shared palette, quantized from a down-sampled mix of all frames.
  var mixed: [UInt8] = []
  for f in frames {
    let total = f.rgba.count / 4
    let stride = max(1, total / 4096)
    var i = 0
    while i < total {
      mixed.append(f.rgba[i * 4])
      mixed.append(f.rgba[i * 4 + 1])
      mixed.append(f.rgba[i * 4 + 2])
      mixed.append(f.rgba[i * 4 + 3])
      i += stride
    }
  }
  let palette = extractPalette(mixed, maxColors: maxColors)
  if palette.isEmpty { 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.
  var tableBits = 1
  while (1 << tableBits) < palette.count { tableBits += 1 }
  let tableSize = 1 << tableBits

  var out: [UInt8] = []
  func u16le(_ n: Int) {
    out.append(UInt8(n & 0xff))
    out.append(UInt8((n >> 8) & 0xff))
  }

  out.append(contentsOf: Array("GIF89a".utf8))
  u16le(frames[0].width)
  u16le(frames[0].height)
  out.append(UInt8(0x80 | (tableBits - 1))); out.append(0); out.append(0) // GCT flag + size; bg; aspect
  for i in 0..<tableSize {
    let sw = i < palette.count ? palette[i] : nil
    out.append(UInt8(sw?.r ?? 0)); out.append(UInt8(sw?.g ?? 0)); out.append(UInt8(sw?.b ?? 0))
  }

  // NETSCAPE loop forever.
  out.append(0x21); out.append(0xff); out.append(0x0b)
  out.append(contentsOf: Array("NETSCAPE2.0".utf8))
  out.append(0x03); out.append(0x01); out.append(0x00); out.append(0x00); out.append(0x00)

  let minCodeSize = max(2, tableBits) // GIF spec floor: 2
  for frame in frames {
    // Graphic control extension: delay in centiseconds, no transparency.
    var cs = Int((Double(frame.delayMs) / 10).rounded())
    cs = max(0, min(0xffff, cs))
    out.append(0x21); out.append(0xf9); out.append(0x04); out.append(0x00)
    u16le(cs)
    out.append(0x00); out.append(0x00)

    out.append(0x2c)
    u16le(0); u16le(0); u16le(frame.width); u16le(frame.height)
    out.append(0x00) // no local color table, no interlace

    let indices = mapToPalette(frame.rgba, palette)
    let data = lzwEncode(minCodeSize, indices)
    out.append(UInt8(minCodeSize))
    var i = 0
    while i < data.count { // sub-blocked every 255 bytes
      let chunkEnd = min(i + 255, data.count)
      out.append(UInt8(chunkEnd - i))
      out.append(contentsOf: data[i..<chunkEnd])
      i = chunkEnd
    }
    out.append(0x00) // block terminator
  }

  out.append(0x3b) // trailer
  return out
}

// Smoke: a 4×2 two-frame animation — red then blue — encodes to a valid
// GIF89a header + trailer.
let red = GifFrameInput(width: 4, height: 2,
                        rgba: (0..<8).flatMap { _ in [200, 40, 40, 255] },
                        delayMs: 100)
let blue = GifFrameInput(width: 4, height: 2,
                        rgba: (0..<8).flatMap { _ in [40, 60, 200, 255] },
                        delayMs: 100)
let gif = encodeGif([red, blue], maxColors: 4)
let hex = gif.prefix(6).map { String($0, radix: 16, uppercase: true) }.joined(separator: " ")
print("encoded \(gif.count) bytes: \(hex)") // expect "47 49 46 38 39 61" — "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 →