"""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}")