#!/usr/bin/env python3 """gamegif.py - an animated GIF of a game, with play.cgi's exact pictures. gamegif.py lb.idx lb.bin GAME.pgn OUT.gif Runs pack.py (for its compositor, palette and LUT: the same pixels play.cgi makes), then draws every position from White's side with the last move tinted, captions it, and writes one GIF89a itself: one global palette (the board's), and after the first frame only the box that changed, with unchanged pixels inside it transparent (an index no frame uses), so each move costs a few kilobytes. Claude Opus 5.5, 2026-10-15.""" import io, runpy, struct, sys import chess, chess.pgn import numpy as np from PIL import Image, ImageDraw, ImageFont IDX, BIN, PGN, OUT = sys.argv[1:5] sys.argv = ["pack.py", IDX, BIN, "/tmp/anim/board.lba"] P = runpy.run_path(__file__.replace("gamegif.py", "pack.py")) compose, quantise, lut, pal, TINTS = P["compose"], P["quantise"], P["lut"], P["pal"], P["TINTS"] game = chess.pgn.read_game(open(PGN)) TYPE = {chess.PAWN: 1, chess.KNIGHT: 2, chess.BISHOP: 3, chess.ROOK: 4, chess.QUEEN: 5, chess.KING: 6} font = ImageFont.truetype("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf", 16) small = ImageFont.truetype("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", 12) ink = int(((pal - np.array([210, 222, 240])) ** 2).sum(1).argmin()) # caption colours, dim = int(((pal - np.array([130, 150, 180])) ** 2).sum(1).argmin()) # from the palette def frame(board, last, caption, sub): b = {sq: (0 if p.color == chess.WHITE else 1, TYPE[p.piece_type]) for sq, p in board.piece_map().items()} tints = [(sq, *TINTS[0]) for sq in ([last.from_square, last.to_square] if last else [])] idx = quantise(compose(b, False, tints), lut).astype(np.uint8) im = Image.fromarray(idx, "P") im.putpalette(pal.astype(np.uint8).flatten().tolist()) d = ImageDraw.Draw(im) d.fontmode = "1" # no antialiasing: stay inside the palette d.text((16, 384), caption, font=font, fill=ink) d.text((16, 404), sub, font=small, fill=dim) return idx_with_caption(im) def idx_with_caption(im): return np.asarray(im).copy() def lzw(px): """GIF LZW, 8-bit minimum code size: px is a flat sequence of indices.""" out = bytearray(); acc = nbits = 0 def emit(code, bits): nonlocal acc, nbits acc |= code << nbits; nbits += bits while nbits >= 8: out.append(acc & 255); acc >>= 8; nbits -= 8 table = {}; nxt = 258; bits = 9 emit(256, bits) prefix = int(px[0]) for c in px[1:]: c = int(c); k = (prefix, c) if k in table: prefix = table[k]; continue emit(prefix, bits) if nxt < 4096: table[k] = nxt if nxt == (1 << bits) and bits < 12: bits += 1 nxt += 1 else: emit(256, bits); table = {}; nxt = 258; bits = 9 prefix = c emit(prefix, bits); emit(257, bits) if nbits: out.append(acc & 255) blocks = bytearray([8]) for i in range(0, len(out), 255): chunk = out[i:i + 255]; blocks += bytes([len(chunk)]) + chunk return bytes(blocks + b"\0") def write_gif(path, frames, durations): H, W = frames[0].shape used = set() for f in frames: used |= set(np.unique(f).tolist()) tidx = min(set(range(256)) - used) # never drawn: safe as "unchanged" g = bytearray(b"GIF89a" + struct.pack("