Spaces:
Sleeping
Sleeping
| """NBA Time Machine video rendering pipeline. | |
| Pure Python (Pillow + ffmpeg). Produces MP4s suitable for social posting. | |
| Pipeline: | |
| 1. Fetch the player's shard JSON from HuggingFace. | |
| 2. Decode the column-oriented shot format. | |
| 3. Bucket shots by season. | |
| 4. Render frames: intro card, then one ~1.1s hold per season, then outro card. | |
| 5. ffmpeg encodes frames -> MP4 at 30fps. | |
| Frame composition (all sizes): | |
| - Background: white | |
| - Player name + season meta at top | |
| - Headshot (square crop) top-right or top-corner | |
| - Half-court SVG drawn via Pillow primitives | |
| - Shots overlaid (dots or hex bins) | |
| - Current season label as overlay text on court | |
| - Branding bar at bottom | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import json | |
| import math | |
| import os | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| from collections import defaultdict | |
| from pathlib import Path | |
| from urllib.request import urlopen | |
| from PIL import Image, ImageDraw, ImageFont | |
| # ---------- Constants ---------- | |
| HF_BASE = ( | |
| "https://huggingface.co/datasets/cdechoch/nba-data-archive/" | |
| "resolve/main/shot-chart-shards" | |
| ) | |
| HEADSHOT_URL = "https://cdn.nba.com/headshots/nba/latest/1040x760/{pid}.png" | |
| FPS = 30 | |
| SEASON_HOLD_FRAMES = 33 # ~1.1 seconds per season at 30fps | |
| INTRO_FRAMES = 30 # 1 second | |
| OUTRO_FRAMES = 30 # 1 second | |
| TRAIL_DEPTH = 3 # how many prior seasons to keep visible in trail mode | |
| # Court coords (stats.nba.com units, 1 = 0.1 ft) | |
| COURT_WIDTH = 500 | |
| COURT_HEIGHT = 470 | |
| RIM_X = 250 | |
| RIM_Y = 52.5 | |
| RIM_RADIUS = 7.5 | |
| PAINT_WIDTH = 160 | |
| PAINT_HEIGHT = 190 | |
| FT_CIRCLE_R = 60 | |
| THREE_R = 237.5 | |
| CORNER_THREE_Y = 140 | |
| CORNER_THREE_X = 220 | |
| RESTRICTED_R = 40 | |
| LC_X = RIM_X - CORNER_THREE_X | |
| RC_X = RIM_X + CORNER_THREE_X | |
| PAINT_LEFT = RIM_X - PAINT_WIDTH / 2 | |
| PAINT_RIGHT = RIM_X + PAINT_WIDTH / 2 | |
| # Output sizes | |
| SIZES = { | |
| "square": (1080, 1080), | |
| "landscape": (1200, 675), | |
| "vertical": (1080, 1920), | |
| } | |
| # Theme | |
| COLORS = { | |
| "bg": (255, 255, 255), | |
| "text": (28, 28, 26), | |
| "muted": (110, 110, 106), | |
| "accent": (8, 88, 158), | |
| "court_line": (42, 42, 40), | |
| "court_bg": (250, 249, 247), | |
| "made": (31, 157, 85), | |
| "made_soft": (31, 157, 85, 165), | |
| "missed": (201, 48, 74), | |
| "missed_soft": (201, 48, 74, 140), | |
| } | |
| # Zone league averages (for hex coloring) | |
| ZONE_LEAGUE_AVG = { | |
| "Restricted Area": 0.62, | |
| "In The Paint (Non-RA)": 0.42, | |
| "Mid-Range": 0.40, | |
| "Above the Break 3": 0.355, | |
| "Left Corner 3": 0.385, | |
| "Right Corner 3": 0.385, | |
| "Backcourt": 0.03, | |
| } | |
| DEFAULT_ZONE_AVG = 0.42 | |
| DELTA_RANGE = 0.10 | |
| # ---------- Font helpers ---------- | |
| def get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: | |
| """Get a TrueType font at the requested size. Falls back to default.""" | |
| candidates = [] | |
| if bold: | |
| candidates = [ | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", | |
| "/System/Library/Fonts/Helvetica.ttc", | |
| "C:\\Windows\\Fonts\\arialbd.ttf", | |
| ] | |
| else: | |
| candidates = [ | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", | |
| "/System/Library/Fonts/Helvetica.ttc", | |
| "C:\\Windows\\Fonts\\arial.ttf", | |
| ] | |
| for path in candidates: | |
| if os.path.exists(path): | |
| try: | |
| return ImageFont.truetype(path, size) | |
| except Exception: | |
| continue | |
| return ImageFont.load_default() | |
| # ---------- Data fetching ---------- | |
| def fetch_shard(pid: str) -> dict: | |
| """Fetch the player shard JSON from HuggingFace.""" | |
| prefix = int(pid) // 100 | |
| url = f"{HF_BASE}/players/{prefix}/{pid}.json" | |
| with urlopen(url, timeout=60) as resp: | |
| return json.loads(resp.read().decode("utf-8")) | |
| def fetch_headshot(pid: str) -> Image.Image | None: | |
| """Fetch headshot from NBA CDN. Returns None on failure.""" | |
| try: | |
| url = HEADSHOT_URL.format(pid=pid) | |
| with urlopen(url, timeout=20) as resp: | |
| return Image.open(io.BytesIO(resp.read())).convert("RGBA") | |
| except Exception as e: | |
| print(f"Headshot fetch failed for {pid}: {e}") | |
| return None | |
| def decode_shard(shard: dict) -> list[dict]: | |
| """Decode the column-oriented shot data into a list of dicts.""" | |
| cols = shard["shots"] | |
| zone_codes = shard.get("zone_codes", []) | |
| n = len(cols["x"]) | |
| out = [] | |
| for i in range(n): | |
| out.append({ | |
| "x": cols["x"][i], | |
| "y": cols["y"][i], | |
| "made": cols["m"][i] == 1, | |
| "three": cols["3"][i] == 1, | |
| "season": cols["s"][i], | |
| "po": cols["po"][i] == 1, | |
| "period": cols["p"][i], | |
| "zone": zone_codes[cols["z"][i]] if cols["z"][i] < len(zone_codes) else "", | |
| }) | |
| return out | |
| def bucket_by_season(shots: list[dict]) -> dict[int, list[dict]]: | |
| """Group shots by season year.""" | |
| buckets = defaultdict(list) | |
| for s in shots: | |
| buckets[s["season"]].append(s) | |
| return dict(buckets) | |
| # ---------- Court rendering (Pillow) ---------- | |
| def draw_court(draw: ImageDraw.ImageDraw, ox: float, oy: float, w: float, h: float): | |
| """Draw a half-court at the given canvas region (ox, oy, w, h). | |
| The court coordinate system is 500 wide x 470 tall; we scale to fit. | |
| """ | |
| sx = w / COURT_WIDTH | |
| sy = h / COURT_HEIGHT | |
| def cx(u): return ox + u * sx | |
| def cy(u): return oy + u * sy | |
| # Court background | |
| draw.rectangle([ox, oy, ox + w, oy + h], fill=COLORS["court_bg"]) | |
| line = COLORS["court_line"] | |
| lw = max(2, int(sx * 1.6)) | |
| # Outer rect | |
| draw.rectangle([ox, oy, ox + w, oy + h], outline=line, width=lw) | |
| # Paint | |
| draw.rectangle([ | |
| cx(PAINT_LEFT), cy(0), | |
| cx(PAINT_RIGHT), cy(PAINT_HEIGHT) | |
| ], outline=line, width=lw) | |
| # Free throw circle | |
| draw.ellipse([ | |
| cx(RIM_X - FT_CIRCLE_R), cy(PAINT_HEIGHT - FT_CIRCLE_R), | |
| cx(RIM_X + FT_CIRCLE_R), cy(PAINT_HEIGHT + FT_CIRCLE_R) | |
| ], outline=line, width=lw) | |
| # Restricted area (semicircle around rim, opening downward toward baseline) | |
| draw.arc([ | |
| cx(RIM_X - RESTRICTED_R), cy(RIM_Y - RESTRICTED_R), | |
| cx(RIM_X + RESTRICTED_R), cy(RIM_Y + RESTRICTED_R) | |
| ], 0, 180, fill=line, width=lw) | |
| # Rim | |
| draw.ellipse([ | |
| cx(RIM_X - RIM_RADIUS), cy(RIM_Y - RIM_RADIUS), | |
| cx(RIM_X + RIM_RADIUS), cy(RIM_Y + RIM_RADIUS) | |
| ], outline=line, width=max(2, int(sx * 2))) | |
| # Backboard | |
| draw.line([cx(RIM_X - 30), cy(40), cx(RIM_X + 30), cy(40)], fill=line, width=lw) | |
| # 3PT corner verticals | |
| draw.line([cx(LC_X), cy(0), cx(LC_X), cy(CORNER_THREE_Y)], fill=line, width=lw) | |
| draw.line([cx(RC_X), cy(0), cx(RC_X), cy(CORNER_THREE_Y)], fill=line, width=lw) | |
| # 3PT arc - the arc from (LC_X, 140) through (250, 290) to (RC_X, 140) | |
| # Pillow's arc takes a bounding box and start/end angles measured clockwise from 3 o'clock. | |
| # Arc center is at (RIM_X, RIM_Y), radius THREE_R. | |
| arc_bbox = [ | |
| cx(RIM_X - THREE_R), cy(RIM_Y - THREE_R), | |
| cx(RIM_X + THREE_R), cy(RIM_Y + THREE_R) | |
| ] | |
| # Calculate angles: angle to (LC_X, 140) from center (RIM_X, RIM_Y) | |
| # In PIL coords, angle 0 = east (right), 90 = south (down), 180 = west (left) | |
| angle_left = math.degrees(math.atan2(CORNER_THREE_Y - RIM_Y, LC_X - RIM_X)) | |
| angle_right = math.degrees(math.atan2(CORNER_THREE_Y - RIM_Y, RC_X - RIM_X)) | |
| # Normalize to 0-360. PIL expects start < end going clockwise from 3 o'clock. | |
| if angle_left < 0: | |
| angle_left += 360 | |
| if angle_right < 0: | |
| angle_right += 360 | |
| # The arc we want goes from left (about 158deg) clockwise through 90 (apex) to right (about 22deg). | |
| # In PIL terms with start=angle_right and end=angle_left, going CW. | |
| draw.arc(arc_bbox, angle_right, angle_left, fill=line, width=lw) | |
| # Half-court line + center half-circle | |
| draw.line([ox, oy + h, ox + w, oy + h], fill=line, width=max(2, int(sx * 2))) | |
| draw.arc([ | |
| cx(RIM_X - 60), cy(COURT_HEIGHT - 60), | |
| cx(RIM_X + 60), cy(COURT_HEIGHT + 60) | |
| ], 180, 360, fill=line, width=lw) | |
| def map_shot(s: dict, ox: float, oy: float, w: float, h: float) -> tuple[float, float]: | |
| """Map a shot's (x, y) into canvas coordinates.""" | |
| sx = w / COURT_WIDTH | |
| sy = h / COURT_HEIGHT | |
| cx = ox + (RIM_X + s["x"]) * sx | |
| cy = oy + (RIM_Y + s["y"]) * sy | |
| return cx, cy | |
| def in_bounds(cx: float, cy: float, ox: float, oy: float, w: float, h: float) -> bool: | |
| return ox <= cx <= ox + w and oy <= cy <= oy + h | |
| def draw_shots_dots( | |
| img: Image.Image, | |
| shots: list[dict], | |
| ox: float, oy: float, w: float, h: float, | |
| opacity: float = 1.0, | |
| ): | |
| """Draw shot dots onto an RGBA image.""" | |
| sx = w / COURT_WIDTH | |
| n = len(shots) | |
| r = int(sx * (2.0 if n > 3000 else 2.4 if n > 800 else 3.0)) | |
| overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) | |
| odraw = ImageDraw.Draw(overlay) | |
| # Misses first, makes on top | |
| misses = [s for s in shots if not s["made"]] | |
| makes = [s for s in shots if s["made"]] | |
| for group, fill, stroke in [ | |
| (misses, (*COLORS["missed"], int(140 * opacity)), (*COLORS["missed"], int(220 * opacity))), | |
| (makes, (*COLORS["made"], int(165 * opacity)), (*COLORS["made"], int(230 * opacity))), | |
| ]: | |
| for s in group: | |
| cx_, cy_ = map_shot(s, ox, oy, w, h) | |
| if not in_bounds(cx_, cy_, ox, oy, w, h): | |
| continue | |
| odraw.ellipse([cx_ - r, cy_ - r, cx_ + r, cy_ + r], fill=fill, outline=stroke) | |
| img.alpha_composite(overlay) | |
| def ramp_color(t: float) -> tuple[int, int, int]: | |
| """Red-gray-green ramp for hex bins. t in [0, 1].""" | |
| if t < 0.5: | |
| k = t / 0.5 | |
| r = int(201 + (240 - 201) * k) | |
| g = int(48 + (240 - 48) * k) | |
| b = int(74 + (240 - 74) * k) | |
| else: | |
| k = (t - 0.5) / 0.5 | |
| r = int(240 + (31 - 240) * k) | |
| g = int(240 + (157 - 240) * k) | |
| b = int(240 + (85 - 240) * k) | |
| return (r, g, b) | |
| def hex_color(fgp: float, zone_avg: float) -> tuple[int, int, int]: | |
| """Zone-aware hex bin color.""" | |
| if zone_avg is None: | |
| zone_avg = DEFAULT_ZONE_AVG | |
| delta = max(-DELTA_RANGE, min(DELTA_RANGE, fgp - zone_avg)) | |
| t = (delta + DELTA_RANGE) / (2 * DELTA_RANGE) | |
| return ramp_color(t) | |
| def draw_shots_hex( | |
| img: Image.Image, | |
| shots: list[dict], | |
| ox: float, oy: float, w: float, h: float, | |
| opacity: float = 1.0, | |
| ): | |
| """Draw shots as hex bins.""" | |
| sx = w / COURT_WIDTH | |
| hex_size = max(8, sx * 13) | |
| hex_w = math.sqrt(3) * hex_size | |
| hex_h = 2 * hex_size | |
| vert_space = hex_h * 0.75 | |
| overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) | |
| odraw = ImageDraw.Draw(overlay) | |
| # Bin shots | |
| bins = {} | |
| for s in shots: | |
| cx_, cy_ = map_shot(s, ox, oy, w, h) | |
| if not in_bounds(cx_, cy_, ox, oy, w, h): | |
| continue | |
| row = int((cy_ - oy) / vert_space) | |
| col_offset = 0 if row % 2 == 0 else hex_w / 2 | |
| col = int(((cx_ - ox) - col_offset) / hex_w) | |
| key = (row, col) | |
| b = bins.setdefault(key, {"made": 0, "total": 0, "cx": 0, "cy": 0, "zones": {}}) | |
| b["total"] += 1 | |
| if s["made"]: | |
| b["made"] += 1 | |
| b["cx"] += cx_ | |
| b["cy"] += cy_ | |
| z = s.get("zone", "") | |
| if z: | |
| b["zones"][z] = b["zones"].get(z, 0) + 1 | |
| max_count = max((b["total"] for b in bins.values()), default=0) | |
| for b in bins.values(): | |
| if b["total"] < 2: | |
| continue | |
| cx_ = b["cx"] / b["total"] | |
| cy_ = b["cy"] / b["total"] | |
| fgp = b["made"] / b["total"] if b["total"] else 0 | |
| # Dominant zone | |
| best_zone = None | |
| best_count = 0 | |
| for z, c in b["zones"].items(): | |
| if c > best_count: | |
| best_zone = z | |
| best_count = c | |
| zone_avg = ZONE_LEAGUE_AVG.get(best_zone, DEFAULT_ZONE_AVG) if best_zone else DEFAULT_ZONE_AVG | |
| size = hex_size * (0.45 + 0.55 * min(1, b["total"] / max(8, max_count / 4))) | |
| color = hex_color(fgp, zone_avg) | |
| # Polygon points | |
| pts = [] | |
| for i in range(6): | |
| angle = math.pi / 6 + (math.pi / 3) * i | |
| pts.append((cx_ + size * math.cos(angle), cy_ + size * math.sin(angle))) | |
| fill = (*color, int(255 * opacity)) | |
| odraw.polygon(pts, fill=fill, outline=(0, 0, 0, int(50 * opacity))) | |
| img.alpha_composite(overlay) | |
| # ---------- Layout helpers ---------- | |
| def get_layout(size: str) -> dict: | |
| """Return layout regions for a given output size.""" | |
| W, H = SIZES[size] | |
| if size == "landscape": | |
| return { | |
| "W": W, "H": H, | |
| "court": (60, 80, 540, H - 160), | |
| "name": (620, 100, W - 670), | |
| "season_label_pos": "court", # in-court overlay | |
| "brand": (0, H - 60, W, 60), | |
| } | |
| elif size == "vertical": | |
| return { | |
| "W": W, "H": H, | |
| "court": (80, 480, W - 160, 940), | |
| "name": (80, 130, W - 160), | |
| "season_label_pos": "court", | |
| "brand": (0, H - 130, W, 130), | |
| } | |
| else: # square | |
| return { | |
| "W": W, "H": H, | |
| "court": (100, 280, W - 200, 720), | |
| "name": (80, 100, W - 160), | |
| "season_label_pos": "court", | |
| "brand": (0, H - 90, W, 90), | |
| } | |
| def season_label_str(year: int) -> str: | |
| return f"{year}-{str((year + 1) % 100).zfill(2)}" | |
| def slug_for_filename(name: str) -> str: | |
| out = [] | |
| for c in name.lower(): | |
| if c.isalnum(): | |
| out.append(c) | |
| elif out and out[-1] != "-": | |
| out.append("-") | |
| s = "".join(out).strip("-") | |
| return s or "player" | |
| # ---------- Frame composition ---------- | |
| def compose_frame( | |
| canvas_size: tuple[int, int], | |
| layout: dict, | |
| player_name: str, | |
| season_label: str, | |
| shots_now: list[dict], | |
| trail_seasons: list[tuple[list[dict], float]], # (shots, opacity) for trailing seasons | |
| view: str, | |
| headshot: Image.Image | None, | |
| brand: str, | |
| intro_alpha: float = 1.0, | |
| ) -> Image.Image: | |
| """Render a single frame as a Pillow Image.""" | |
| W, H = canvas_size | |
| img = Image.new("RGBA", (W, H), (*COLORS["bg"], 255)) | |
| draw = ImageDraw.Draw(img) | |
| # Court | |
| cx, cy, cw, ch = layout["court"] | |
| draw_court(draw, cx, cy, cw, ch) | |
| # Trail shots first (faint), then current on top | |
| for trail_shots, op in trail_seasons: | |
| if not trail_shots: | |
| continue | |
| if view == "hex": | |
| draw_shots_hex(img, trail_shots, cx, cy, cw, ch, opacity=op) | |
| else: | |
| draw_shots_dots(img, trail_shots, cx, cy, cw, ch, opacity=op) | |
| if shots_now: | |
| if view == "hex": | |
| draw_shots_hex(img, shots_now, cx, cy, cw, ch, opacity=1.0) | |
| else: | |
| draw_shots_dots(img, shots_now, cx, cy, cw, ch, opacity=1.0) | |
| # Headshot (if loaded) | |
| if headshot is not None: | |
| size_name = "square" # fallback | |
| if H == 675: size_name = "landscape" | |
| elif H == 1920: size_name = "vertical" | |
| hs_size = 240 if size_name == "vertical" else 180 | |
| hs_x = W - hs_size - 70 | |
| hs_y = 60 | |
| # Square crop the headshot | |
| hw, hh = headshot.size | |
| side = min(hw, hh) | |
| left = (hw - side) // 2 | |
| top = (hh - side) // 2 | |
| cropped = headshot.crop((left, top, left + side, top + side)) | |
| resized = cropped.resize((hs_size, hs_size), Image.LANCZOS) | |
| # Rounded corners | |
| mask = Image.new("L", (hs_size, hs_size), 0) | |
| mdraw = ImageDraw.Draw(mask) | |
| mdraw.rounded_rectangle([0, 0, hs_size, hs_size], radius=14, fill=255) | |
| img.paste(resized, (hs_x, hs_y), mask) | |
| # Player name + season subtitle | |
| nx, ny, nw = layout["name"] | |
| name_size = 80 if H == 1920 else (56 if H == 675 else 64) | |
| f_name = get_font(name_size, bold=True) | |
| draw.text((nx, ny), player_name, fill=COLORS["text"], font=f_name) | |
| f_sub = get_font(int(name_size * 0.42)) | |
| draw.text((nx, ny + int(name_size * 1.05)), f"Career shot chart, season by season", | |
| fill=COLORS["muted"], font=f_sub) | |
| # Season overlay on court (big season label in top-left of court area) | |
| if season_label: | |
| f_season = get_font(int(ch * 0.07), bold=True) | |
| draw.text((cx + 18, cy + 14), season_label, | |
| fill=COLORS["text"], font=f_season) | |
| # Branding bar | |
| bx, by, bw, bh = layout["brand"] | |
| draw.rectangle([bx, by, bx + bw, by + bh], fill=COLORS["accent"]) | |
| brand_text = "HoopsHype.com" if brand == "hoopshype" else "HoopsMatic.com" | |
| f_brand = get_font(int(bh * 0.5) if bh > 90 else 40, bold=True) | |
| # Center the brand text | |
| bbox = draw.textbbox((0, 0), brand_text, font=f_brand) | |
| tw = bbox[2] - bbox[0] | |
| th = bbox[3] - bbox[1] | |
| draw.text( | |
| (bx + (bw - tw) // 2, by + (bh - th) // 2 - 2), | |
| brand_text, fill=(255, 255, 255), font=f_brand | |
| ) | |
| # Intro alpha fade | |
| if intro_alpha < 1.0: | |
| fade = Image.new("RGBA", (W, H), (*COLORS["bg"], int(255 * (1 - intro_alpha)))) | |
| img = Image.alpha_composite(img, fade) | |
| return img.convert("RGB") | |
| def compose_title_card( | |
| canvas_size: tuple[int, int], | |
| layout: dict, | |
| title: str, | |
| subtitle: str, | |
| brand: str, | |
| is_outro: bool = False, | |
| ) -> Image.Image: | |
| """Render an intro or outro title card (simpler than animation frames).""" | |
| W, H = canvas_size | |
| img = Image.new("RGBA", (W, H), (*COLORS["bg"], 255)) | |
| draw = ImageDraw.Draw(img) | |
| # Big centered title | |
| title_size = 110 if H == 1920 else (70 if H == 675 else 90) | |
| f_title = get_font(title_size, bold=True) | |
| bbox = draw.textbbox((0, 0), title, font=f_title) | |
| tw = bbox[2] - bbox[0] | |
| th = bbox[3] - bbox[1] | |
| cy_title = (H // 2) - th | |
| draw.text(((W - tw) // 2, cy_title), title, fill=COLORS["text"], font=f_title) | |
| # Subtitle | |
| sub_size = int(title_size * 0.45) | |
| f_sub = get_font(sub_size) | |
| bbox = draw.textbbox((0, 0), subtitle, font=f_sub) | |
| sw = bbox[2] - bbox[0] | |
| draw.text(((W - sw) // 2, cy_title + th + 20), subtitle, | |
| fill=COLORS["muted"], font=f_sub) | |
| # Branding bar at bottom | |
| bx, by, bw, bh = layout["brand"] | |
| draw.rectangle([bx, by, bx + bw, by + bh], fill=COLORS["accent"]) | |
| brand_text = "HoopsHype.com" if brand == "hoopshype" else "HoopsMatic.com" | |
| f_brand = get_font(int(bh * 0.5) if bh > 90 else 40, bold=True) | |
| bbox = draw.textbbox((0, 0), brand_text, font=f_brand) | |
| tw = bbox[2] - bbox[0] | |
| th = bbox[3] - bbox[1] | |
| draw.text( | |
| (bx + (bw - tw) // 2, by + (bh - th) // 2 - 2), | |
| brand_text, fill=(255, 255, 255), font=f_brand | |
| ) | |
| return img.convert("RGB") | |
| # ---------- Main render function ---------- | |
| def render_video( | |
| pid: str, | |
| player_name: str, | |
| size: str, | |
| mode: str, | |
| view: str, | |
| brand: str, | |
| out_path: str, | |
| progress_cb=None, | |
| ): | |
| """Render the full MP4 for the given player + options. | |
| progress_cb(pct, msg) called periodically with float pct in [0,1]. | |
| """ | |
| def _progress(pct, msg): | |
| if progress_cb: | |
| progress_cb(pct, msg) | |
| _progress(0.02, "Loading shot data...") | |
| shard = fetch_shard(pid) | |
| shots = decode_shard(shard) | |
| buckets = bucket_by_season(shots) | |
| seasons = sorted(buckets.keys()) | |
| if not seasons: | |
| raise RuntimeError("Player has no shots in the dataset.") | |
| _progress(0.08, "Loading headshot...") | |
| headshot = fetch_headshot(pid) | |
| canvas_size = SIZES[size] | |
| layout = get_layout(size) | |
| first_season = seasons[0] | |
| last_season = seasons[-1] | |
| seasons_label = f"{first_season}-{str((last_season + 1) % 100).zfill(2)}" | |
| total_shots = sum(len(b) for b in buckets.values()) | |
| # Build the frame list | |
| frame_dir = Path(tempfile.mkdtemp(prefix="tm_frames_")) | |
| frame_paths = [] | |
| total_frames_est = INTRO_FRAMES + len(seasons) * SEASON_HOLD_FRAMES + OUTRO_FRAMES | |
| frame_idx = 0 | |
| try: | |
| # Intro card | |
| _progress(0.10, "Rendering intro...") | |
| intro_img = compose_title_card( | |
| canvas_size, layout, | |
| title=player_name, | |
| subtitle=f"Shot chart evolution {seasons_label}", | |
| brand=brand, | |
| ) | |
| for _ in range(INTRO_FRAMES): | |
| p = frame_dir / f"f_{frame_idx:05d}.png" | |
| intro_img.save(p, "PNG") | |
| frame_paths.append(p) | |
| frame_idx += 1 | |
| # Animation frames - one block per season | |
| for si, year in enumerate(seasons): | |
| season_shots = buckets[year] | |
| # Trail seasons (current minus 1, 2, 3) with decreasing opacity | |
| trail = [] | |
| if mode == "trail": | |
| for offset in range(TRAIL_DEPTH, 0, -1): | |
| prev_idx = si - offset | |
| if prev_idx < 0: | |
| continue | |
| prev_shots = buckets[seasons[prev_idx]] | |
| op = 0.18 * (1 - (offset - 1) / TRAIL_DEPTH) + 0.05 | |
| trail.append((prev_shots, op)) | |
| frame_img = compose_frame( | |
| canvas_size, layout, | |
| player_name=player_name, | |
| season_label=season_label_str(year), | |
| shots_now=season_shots, | |
| trail_seasons=trail, | |
| view=view, | |
| headshot=headshot, | |
| brand=brand, | |
| ) | |
| # Hold this frame for SEASON_HOLD_FRAMES | |
| for _ in range(SEASON_HOLD_FRAMES): | |
| p = frame_dir / f"f_{frame_idx:05d}.png" | |
| frame_img.save(p, "PNG") | |
| frame_paths.append(p) | |
| frame_idx += 1 | |
| pct = 0.15 + 0.75 * (si + 1) / len(seasons) | |
| _progress(pct, f"Rendered season {year} ({si + 1}/{len(seasons)})") | |
| # Outro card | |
| _progress(0.92, "Rendering outro...") | |
| outro_img = compose_title_card( | |
| canvas_size, layout, | |
| title=player_name, | |
| subtitle=f"{total_shots:,} career shots", | |
| brand=brand, | |
| is_outro=True, | |
| ) | |
| for _ in range(OUTRO_FRAMES): | |
| p = frame_dir / f"f_{frame_idx:05d}.png" | |
| outro_img.save(p, "PNG") | |
| frame_paths.append(p) | |
| frame_idx += 1 | |
| # ffmpeg encode | |
| _progress(0.95, "Encoding MP4...") | |
| cmd = [ | |
| "ffmpeg", "-y", | |
| "-framerate", str(FPS), | |
| "-i", str(frame_dir / "f_%05d.png"), | |
| "-c:v", "libx264", | |
| "-pix_fmt", "yuv420p", | |
| "-crf", "20", | |
| "-preset", "medium", | |
| "-movflags", "+faststart", | |
| str(out_path), | |
| ] | |
| result = subprocess.run(cmd, capture_output=True, text=True) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"ffmpeg failed: {result.stderr[-500:]}") | |
| _progress(1.0, "Done.") | |
| return out_path | |
| finally: | |
| # Clean up frame dir | |
| shutil.rmtree(frame_dir, ignore_errors=True) | |
| if __name__ == "__main__": | |
| # Quick local test | |
| import sys | |
| pid = sys.argv[1] if len(sys.argv) > 1 else "201939" # Curry | |
| out = "/tmp/test.mp4" | |
| render_video( | |
| pid=pid, player_name="Stephen Curry", size="square", | |
| mode="trail", view="dots", brand="hoopsmatic", | |
| out_path=out, | |
| progress_cb=lambda p, m: print(f" {p*100:.0f}% - {m}"), | |
| ) | |
| print(f"Wrote {out}") | |