"""Replays one event's pixel placement history into an animated GIF. Unlike canvas.py (which only ever reflects the current state), this reads the durable PixelPlacement history and reconstructs every intermediate state of the grid, one frame per placement (or sampled down if there are more placements than MAX_FRAMES), so an admin can watch that event's board evolve. """ from io import BytesIO from PIL import Image from events.models import Event from .models import PALETTE, PixelPlacement, PlaceSettings SCALE = 8 MAX_FRAMES = 300 FRAME_DURATION_MS = 80 FINAL_FRAME_HOLD_MS = 3000 _PALETTE_RGB = [ tuple(int(hex_color[i : i + 2], 16) for i in (1, 3, 5)) for hex_color in PALETTE ] def build_evolution_gif(event: Event, usernames: list[str] | None = None) -> bytes: """Renders `event`'s full placement history as an animated GIF, upscaled for visibility. Returns GIF bytes, even if there are zero placements. If `usernames` is given, only those players' placements are replayed (combined on the same board). Always replays at the *current* grid dimensions - a placement made before a resize that now falls outside them is skipped rather than raising.""" place_settings = PlaceSettings.current(event) width, height = place_settings.width, place_settings.height placements = PixelPlacement.objects.filter(event=event) if usernames: placements = placements.filter(player__username__in=usernames) placements = list( placements.order_by("placed_at").values_list("x", "y", "color_index") ) grid = bytearray(width * height) frames: list[Image.Image] = [] step = max(1, len(placements) // MAX_FRAMES) for i, (x, y, color_index) in enumerate(placements): if x >= width or y >= height: continue grid[y * width + x] = color_index if i % step == 0 or i == len(placements) - 1: frames.append(_render_frame(grid, width, height)) if not frames: frames.append(_render_frame(grid, width, height)) buffer = BytesIO() durations = [FRAME_DURATION_MS] * len(frames) durations[-1] = FINAL_FRAME_HOLD_MS frames[0].save( buffer, format="GIF", save_all=True, append_images=frames[1:], duration=durations, loop=0, disposal=2, ) return buffer.getvalue() def _render_frame(grid: bytearray, width: int, height: int) -> Image.Image: image = Image.new("RGB", (width, height)) image.putdata([_PALETTE_RGB[c] for c in grid]) return image.resize((width * SCALE, height * SCALE), Image.NEAREST)