| """ |
| WheelSpin GIF Generator API — HuggingFace Space endpoint. |
| |
| Generatore custom di GIF animate con ruota che gira, realizzato con Pillow. |
| Design ispirato a "Wheel of Fortune": colori vivaci, testo grande e leggibile, |
| cerchio centrale con label "Spin", bordi netti, animazione fluida con easing. |
| """ |
|
|
| import io |
| import math |
| import os |
| import random |
| import time |
| from collections import deque |
| from contextlib import asynccontextmanager |
| from datetime import datetime, timezone |
|
|
| from PIL import Image, ImageDraw, ImageFont |
| from fastapi import FastAPI, Query, HTTPException |
| from fastapi.responses import HTMLResponse, Response, JSONResponse |
|
|
| try: |
| import psutil as _psutil |
| _psutil.cpu_percent(interval=None) |
| _proc = _psutil.Process() |
| _proc.cpu_percent(interval=None) |
| _HAS_PSUTIL = True |
| except Exception: |
| _HAS_PSUTIL = False |
| _proc = None |
|
|
| |
| API_KEY = os.environ.get("API_KEY", "") |
|
|
| |
| WHEEL_COLORS = [ |
| "#e74c3c", |
| "#f39c12", |
| "#f1c40f", |
| "#2ecc71", |
| "#1abc9c", |
| "#3498db", |
| "#9b59b6", |
| "#e91e63", |
| "#00bcd4", |
| "#ff9800", |
| "#8bc34a", |
| "#ff5722", |
| ] |
|
|
| |
| _stats = { |
| "total_spins": 0, |
| "start_time": time.time(), |
| "log": deque(maxlen=50), |
| } |
|
|
| def _log(event: str, detail: str = "") -> None: |
| ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") |
| _stats["log"].appendleft({"ts": ts, "event": event, "detail": detail}) |
|
|
|
|
| |
| |
| |
|
|
| def _get_font(size: int): |
| """Cerca un font bold di sistema, fallback al default.""" |
| font_paths = [ |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", |
| "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", |
| "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", |
| "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", |
| ] |
| for fp in font_paths: |
| if os.path.exists(fp): |
| return ImageFont.truetype(fp, size) |
| try: |
| return ImageFont.truetype("arial.ttf", size) |
| except Exception: |
| return ImageFont.load_default() |
|
|
|
|
| def _hex_to_rgb(hex_color: str): |
| h = hex_color.lstrip("#") |
| return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) |
|
|
|
|
| def _darken(rgb, factor=0.7): |
| return tuple(int(c * factor) for c in rgb) |
|
|
|
|
| def _is_dark(rgb): |
| return (rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114) < 150 |
|
|
|
|
| def _draw_wheel(segments, size, rotation_deg, colors): |
| """Disegna un singolo frame della ruota.""" |
| img = Image.new("RGBA", (size, size), (255, 255, 255, 0)) |
| draw = ImageDraw.Draw(img) |
|
|
| cx, cy = size // 2, size // 2 |
| n = len(segments) |
| angle_per = 360.0 / n |
| margin = int(size * 0.04) |
| radius = (size // 2) - margin |
|
|
| |
| shadow_offset = int(size * 0.008) |
| draw.ellipse( |
| [margin + shadow_offset, margin + shadow_offset, |
| size - margin + shadow_offset, size - margin + shadow_offset], |
| fill=(0, 0, 0, 60) |
| ) |
|
|
| |
| outer_ring = int(size * 0.015) |
| draw.ellipse( |
| [margin - outer_ring, margin - outer_ring, |
| size - margin + outer_ring, size - margin + outer_ring], |
| fill=(40, 40, 40, 255) |
| ) |
|
|
| |
| for i in range(n): |
| start_angle = rotation_deg + i * angle_per - 90 |
| color_rgb = _hex_to_rgb(colors[i % len(colors)]) |
|
|
| |
| draw.pieslice( |
| [margin, margin, size - margin, size - margin], |
| start=start_angle, |
| end=start_angle + angle_per, |
| fill=color_rgb, |
| outline=(255, 255, 255), |
| width=3 |
| ) |
|
|
| |
| for i in range(n): |
| angle_rad = math.radians(rotation_deg + i * angle_per - 90) |
| x_end = cx + radius * math.cos(angle_rad) |
| y_end = cy + radius * math.sin(angle_rad) |
| draw.line([(cx, cy), (x_end, y_end)], fill=(255, 255, 255), width=3) |
|
|
| |
| font_size = max(14, min(int(size * 0.045), int(size * 0.09 - n * 1.5))) |
| font = _get_font(font_size) |
| font_small = _get_font(max(10, font_size - 4)) |
|
|
| for i in range(n): |
| mid_angle_deg = rotation_deg + i * angle_per + angle_per / 2 - 90 |
| mid_angle_rad = math.radians(mid_angle_deg) |
|
|
| |
| text_radius = radius * 0.62 |
| tx = cx + text_radius * math.cos(mid_angle_rad) |
| ty = cy + text_radius * math.sin(mid_angle_rad) |
|
|
| label = segments[i] |
| |
| if len(label) > 12: |
| label = label[:11] + "…" |
|
|
| color_rgb = _hex_to_rgb(colors[i % len(colors)]) |
| text_color = (255, 255, 255) if _is_dark(color_rgb) else (30, 30, 30) |
|
|
| |
| |
| use_font = font if len(label) <= 8 else font_small |
| bbox = use_font.getbbox(label) |
| tw = bbox[2] - bbox[0] |
| th = bbox[3] - bbox[1] |
|
|
| txt_img = Image.new("RGBA", (tw + 10, th + 10), (0, 0, 0, 0)) |
| txt_draw = ImageDraw.Draw(txt_img) |
|
|
| |
| txt_draw.text((6, 6), label, font=use_font, fill=(0, 0, 0, 100)) |
| |
| txt_draw.text((5, 5), label, font=use_font, fill=text_color) |
|
|
| |
| rot_angle = -mid_angle_deg |
| txt_rot = txt_img.rotate(rot_angle, expand=True, resample=Image.BICUBIC) |
|
|
| |
| paste_x = int(tx - txt_rot.width / 2) |
| paste_y = int(ty - txt_rot.height / 2) |
| img.paste(txt_rot, (paste_x, paste_y), txt_rot) |
|
|
| |
| center_r = int(radius * 0.18) |
|
|
| |
| draw.ellipse( |
| [cx - center_r + 2, cy - center_r + 2, |
| cx + center_r + 2, cy + center_r + 2], |
| fill=(0, 0, 0, 80) |
| ) |
|
|
| |
| draw.ellipse( |
| [cx - center_r - 4, cy - center_r - 4, |
| cx + center_r + 4, cy + center_r + 4], |
| fill=(255, 255, 255) |
| ) |
|
|
| |
| draw.ellipse( |
| [cx - center_r, cy - center_r, |
| cx + center_r, cy + center_r], |
| fill=(30, 30, 30) |
| ) |
|
|
| |
| spin_font = _get_font(int(center_r * 0.75)) |
| bbox = spin_font.getbbox("Spin") |
| sw = bbox[2] - bbox[0] |
| sh = bbox[3] - bbox[1] |
| draw.text( |
| (cx - sw // 2, cy - sh // 2 - 2), |
| "Spin", |
| font=spin_font, |
| fill=(255, 255, 255) |
| ) |
|
|
| |
| arrow_size = int(size * 0.04) |
| arrow_y = margin - outer_ring + 2 |
| draw.polygon( |
| [ |
| (cx, arrow_y + arrow_size * 2), |
| (cx - arrow_size, arrow_y), |
| (cx + arrow_size, arrow_y), |
| ], |
| fill=(220, 20, 20), |
| outline=(255, 255, 255), |
| width=2 |
| ) |
|
|
| return img |
|
|
|
|
| def _ease_out_quint(t): |
| """Easing quintico: decelerazione molto più morbida e naturale.""" |
| return 1 - (1 - t) ** 5 |
|
|
|
|
| def generate_spin_gif(segments, size=500, total_frames=150, fps=50): |
| """ |
| Genera una GIF animata della ruota che gira e si ferma su un vincitore casuale. |
| 150 frame a 50fps = ~3s di animazione fluida + rallentamento finale. |
| Ritorna (gif_bytes, winner_name). |
| """ |
| n = len(segments) |
| colors = [WHEEL_COLORS[i % len(WHEEL_COLORS)] for i in range(n)] |
|
|
| |
| winner_idx = random.randint(0, n - 1) |
| angle_per = 360.0 / n |
|
|
| |
| target_center = -(winner_idx * angle_per + angle_per / 2) |
| |
| total_spin = 360 * random.randint(5, 8) + target_center |
| |
| jitter = random.uniform(-angle_per * 0.3, angle_per * 0.3) |
| total_spin += jitter |
|
|
| frames = [] |
| durations = [] |
| frame_duration = max(20, int(1000 / fps)) |
|
|
| for f in range(total_frames): |
| t = f / (total_frames - 1) |
| eased = _ease_out_quint(t) |
| current_rotation = total_spin * eased |
|
|
| frame = _draw_wheel(segments, size, current_rotation, colors) |
| |
| bg = Image.new("RGB", (size, size), (255, 255, 255)) |
| bg.paste(frame, mask=frame.split()[3]) |
| frames.append(bg) |
|
|
| |
| if t < 0.85: |
| durations.append(frame_duration) |
| elif t < 0.95: |
| durations.append(frame_duration * 2) |
| else: |
| durations.append(frame_duration * 3) |
|
|
| |
| durations[-1] = 1500 |
|
|
| |
| buf = io.BytesIO() |
| frames[0].save( |
| buf, |
| format="GIF", |
| save_all=True, |
| append_images=frames[1:], |
| duration=durations, |
| loop=0, |
| optimize=False, |
| ) |
| gif_data = buf.getvalue() |
|
|
| winner = segments[winner_idx] |
| return gif_data, winner |
|
|
|
|
| |
| |
| |
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| _log("startup", "WheelSpin GIF Generator ready") |
| yield |
|
|
| app = FastAPI(title="WheelSpin GIF Generator", lifespan=lifespan) |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def root(): |
| uptime = int(time.time() - _stats["start_time"]) |
| return f""" |
| <html><body style="font-family:monospace;max-width:700px;margin:40px auto"> |
| <h1>🎡 WheelSpin GIF Generator</h1> |
| <p>Genera GIF animate di ruote che girano.</p> |
| <h3>Endpoint</h3> |
| <code>GET /spin?segments=Pizza&segments=Sushi&segments=Burger</code> |
| <p>Restituisce un GIF animato della ruota che gira e si ferma su un vincitore casuale.</p> |
| <h3>Parametri</h3> |
| <ul> |
| <li><b>segments</b> (ripetibile) — le opzioni della ruota (min 2, max 12)</li> |
| <li><b>size</b> (opzionale, default 500) — dimensione in pixel</li> |
| </ul> |
| <h3>Stats</h3> |
| <p>Uptime: {uptime}s | Spins: {_stats['total_spins']}</p> |
| </body></html> |
| """ |
|
|
|
|
| @app.get("/health") |
| async def health(): |
| return {"status": "ok", "spins": _stats["total_spins"]} |
|
|
|
|
| @app.get("/spin") |
| async def spin( |
| segments: list[str] = Query(..., min_length=1, max_length=30), |
| size: int = Query(default=500, ge=200, le=800), |
| ): |
| if len(segments) < 2: |
| raise HTTPException(400, "Servono almeno 2 segmenti") |
| if len(segments) > 12: |
| raise HTTPException(400, "Massimo 12 segmenti") |
|
|
| t0 = time.time() |
|
|
| try: |
| gif_data, winner = generate_spin_gif( |
| segments=segments, |
| size=size, |
| total_frames=150, |
| fps=50, |
| ) |
|
|
| elapsed = round(time.time() - t0, 2) |
| _stats["total_spins"] += 1 |
| _log("spin", f"segments={len(segments)} winner={winner} time={elapsed}s size={len(gif_data)//1024}KB") |
|
|
| return Response( |
| content=gif_data, |
| media_type="image/gif", |
| headers={ |
| "X-Winner": winner, |
| "X-Generation-Time": str(elapsed), |
| "Cache-Control": "no-cache", |
| }, |
| ) |
|
|
| except Exception as e: |
| _log("error", str(e)) |
| import traceback |
| traceback.print_exc() |
| raise HTTPException(500, f"Errore nella generazione: {str(e)}") |
|
|