josiefer's picture
Initial: NBA Time Machine Video Generator
af3bdf8
Raw
History Blame Contribute Delete
23.4 kB
"""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}")