feat: clock + place games
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user