Files

81 lines
2.6 KiB
Python

"""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)