78 lines
2.4 KiB
Python
78 lines
2.4 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) -> bytes:
|
|
"""Renders `event`'s full placement history as an animated GIF, upscaled
|
|
for visibility. Returns GIF bytes, even if there are zero placements.
|
|
|
|
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)
|
|
.order_by("placed_at")
|
|
.values_list("x", "y", "color_index")
|
|
)
|
|
placements = list(placements)
|
|
|
|
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)
|