#!/usr/bin/env python3 """Turn a sl0pboy `.gbv` frame capture into an animated GIF. Capture one with the emulator's control socket: ./gbctl record start /abs/path/out.gbv 2 # 2 = capture every 2nd frame (~30fps) ...play... ./gbctl record stop Then: python3 tools/gbgif.py out.gbv out.gif [--scale 3] [--fps 30] [--start N] [--max N] The .gbv format is a 10-byte header ("GBCV", u16 width, u16 height, u16 ms-per-frame, little-endian) followed by raw width*height*3 RGB frames. """ import argparse, struct, sys from PIL import Image def load(path): with open(path, "rb") as f: data = f.read() if data[:4] != b"GBCV": sys.exit("not a GBCV capture (bad magic)") w, h, ms = struct.unpack_from("= args.max: break im = Image.frombytes("RGB", (w, h), bytes(buf)) if args.scale != 1: im = im.resize((w * args.scale, h * args.scale), Image.NEAREST) frames.append(im) if not frames: sys.exit("no frames matched") if args.png: for i, im in enumerate(frames): im.save(f"{args.output}{i:04d}.png") print(f"wrote {len(frames)} PNG frames -> {args.output}NNNN.png") return dur = (1000.0 / args.fps) if args.fps > 0 else max(ms_hdr, 1) * args.stride # Build one shared palette from a montage of sampled frames so the GIF # doesn't flicker between per-frame palettes (GB uses few colors anyway). sample = frames[:: max(1, len(frames) // 8)][:8] or frames[:1] fw, fh = frames[0].size montage = Image.new("RGB", (fw, fh * len(sample))) for i, s in enumerate(sample): montage.paste(s, (0, i * fh)) pal = montage.quantize(colors=256, method=Image.MEDIANCUT) pframes = [f.quantize(palette=pal, dither=Image.NONE) for f in frames] pframes[0].save(args.output, save_all=True, append_images=pframes[1:], duration=dur, loop=0, disposal=1, optimize=True) print(f"{args.output}: {len(frames)} frames, {fw}x{fh}, {1000.0/dur:.1f} fps") if __name__ == "__main__": main()