Download app.py from easterbin/wheelspin: direct link, hf CLI and curl.
- Browser
- Download file 12.2 kB
-
https://huggingface.co/spaces/easterbin/wheelspin/resolve/main/app.py
- Command line
-
hf download hf://spaces/easterbin/wheelspin/app.py
-
curl -L -o app.py https://huggingface.co/spaces/easterbin/wheelspin/resolve/main/app.py
12.2 kB
| """ | |
| 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 | |
| # -- Config ------------------------------------------------------------------ | |
| API_KEY = os.environ.get("API_KEY", "") | |
| # Colori vivaci stile Wheel of Fortune (dal reference) | |
| WHEEL_COLORS = [ | |
| "#e74c3c", # rosso | |
| "#f39c12", # arancione | |
| "#f1c40f", # giallo | |
| "#2ecc71", # verde | |
| "#1abc9c", # turchese | |
| "#3498db", # blu | |
| "#9b59b6", # viola | |
| "#e91e63", # rosa | |
| "#00bcd4", # ciano | |
| "#ff9800", # arancione scuro | |
| "#8bc34a", # lime | |
| "#ff5722", # rosso-arancio | |
| ] | |
| # -- Stats ------------------------------------------------------------------- | |
| _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}) | |
| # ============================================================================ | |
| # Custom Wheel Generator | |
| # ============================================================================ | |
| 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) # margine esterno | |
| radius = (size // 2) - margin | |
| # -- Ombra esterna della ruota -- | |
| 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) | |
| ) | |
| # -- Bordo esterno (anello scuro) -- | |
| 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) | |
| ) | |
| # -- Disegna segmenti -- | |
| for i in range(n): | |
| start_angle = rotation_deg + i * angle_per - 90 # -90 per iniziare da sopra | |
| color_rgb = _hex_to_rgb(colors[i % len(colors)]) | |
| # Segmento principale | |
| 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 | |
| ) | |
| # -- Bordi tra segmenti (linee bianche più spesse) -- | |
| 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) | |
| # -- Testo sui segmenti -- | |
| 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) | |
| # Posiziona il testo al ~60% del raggio | |
| 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] | |
| # Tronca testo lungo | |
| 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) | |
| # Ruota il testo lungo il segmento | |
| # Crea un'immagine temporanea per il testo ruotato | |
| 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) | |
| # Ombra del testo | |
| txt_draw.text((6, 6), label, font=use_font, fill=(0, 0, 0, 100)) | |
| # Testo | |
| txt_draw.text((5, 5), label, font=use_font, fill=text_color) | |
| # Ruota l'immagine del testo | |
| rot_angle = -mid_angle_deg # PIL ruota in senso antiorario | |
| txt_rot = txt_img.rotate(rot_angle, expand=True, resample=Image.BICUBIC) | |
| # Incolla centrato sulla posizione | |
| 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) | |
| # -- Cerchio centrale "Spin" -- | |
| center_r = int(radius * 0.18) | |
| # Ombra del cerchio centrale | |
| draw.ellipse( | |
| [cx - center_r + 2, cy - center_r + 2, | |
| cx + center_r + 2, cy + center_r + 2], | |
| fill=(0, 0, 0, 80) | |
| ) | |
| # Bordo bianco | |
| draw.ellipse( | |
| [cx - center_r - 4, cy - center_r - 4, | |
| cx + center_r + 4, cy + center_r + 4], | |
| fill=(255, 255, 255) | |
| ) | |
| # Cerchio nero | |
| draw.ellipse( | |
| [cx - center_r, cy - center_r, | |
| cx + center_r, cy + center_r], | |
| fill=(30, 30, 30) | |
| ) | |
| # Testo "Spin" | |
| 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) | |
| ) | |
| # -- Indicatore / freccia in alto -- | |
| 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)] | |
| # Scegli vincitore | |
| winner_idx = random.randint(0, n - 1) | |
| angle_per = 360.0 / n | |
| # Angolo finale: la freccia in alto punta al centro del segmento vincente | |
| target_center = -(winner_idx * angle_per + angle_per / 2) | |
| # Più giri completi per effetto spinning dinamico | |
| total_spin = 360 * random.randint(5, 8) + target_center | |
| # Piccola variazione random dentro il segmento | |
| jitter = random.uniform(-angle_per * 0.3, angle_per * 0.3) | |
| total_spin += jitter | |
| frames = [] | |
| durations = [] | |
| frame_duration = max(20, int(1000 / fps)) # 20ms = 50fps (GIF min è 20ms) | |
| for f in range(total_frames): | |
| t = f / (total_frames - 1) # 0..1 | |
| eased = _ease_out_quint(t) | |
| current_rotation = total_spin * eased | |
| frame = _draw_wheel(segments, size, current_rotation, colors) | |
| # Converti RGBA -> RGB per GIF (sfondo bianco) | |
| bg = Image.new("RGB", (size, size), (255, 255, 255)) | |
| bg.paste(frame, mask=frame.split()[3]) | |
| frames.append(bg) | |
| # Durata costante per smoothness, rallenta solo negli ultimi frame | |
| if t < 0.85: | |
| durations.append(frame_duration) # 20ms = fluido | |
| elif t < 0.95: | |
| durations.append(frame_duration * 2) # 40ms = leggero rallentamento | |
| else: | |
| durations.append(frame_duration * 3) # 60ms = suspense finale | |
| # Ultimo frame: pausa lunga per mostrare il risultato | |
| durations[-1] = 1500 | |
| # Salva GIF | |
| 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 | |
| # ============================================================================ | |
| # FastAPI App & Endpoints | |
| # ============================================================================ | |
| async def lifespan(app: FastAPI): | |
| _log("startup", "WheelSpin GIF Generator ready") | |
| yield | |
| app = FastAPI(title="WheelSpin GIF Generator", lifespan=lifespan) | |
| 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> | |
| """ | |
| async def health(): | |
| return {"status": "ok", "spins": _stats["total_spins"]} | |
| 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)}") | |