""" gob.py Handles GOB replay files: renders MP4 videos and exports slim JSON for the web canvas replay viewer. Output mode is picked from the output file extension. Usage: python -m BOT.gob # render video python -m BOT.gob # export json Public API: render_gob(d, out_path, fps, speed, n_workers, progress_cb) load_gob_file(gob_path) export_replay_json(gob_path) """ # Standard Library Imports import json import math import os import subprocess import sys import threading import urllib.request from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path from typing import Any, Callable, Optional # Third-Party Library Imports import numpy as np import pygob import zstandard as zstd from .utils import REPLAYS_DIR from PIL import Image, ImageDraw, ImageFilter, ImageFont # Make SHARED (sibling of SREBOT under BOTS/) importable _SHARED_DIR = Path(__file__).resolve().parents[2] / "SHARED" if str(_SHARED_DIR) not in sys.path: sys.path.insert(0, str(_SHARED_DIR)) try: from data_parser import ( LangTableReader as _LangTableReader, WeaponTableReader as _WeaponTableReader, apply_vehicle_name_filters as _apply_filters, ) _lang = _LangTableReader("English") _weapons = _WeaponTableReader("English") def _translate_vehicle(internal: str) -> str: name = _lang.get_translate(internal) return _apply_filters(name) if name else internal def _translate_weapon(internal: str) -> str: return _weapons.get_translate(internal) or internal except Exception: def _translate_vehicle(internal: str) -> str: # type: ignore[misc] return internal def _translate_weapon(internal: str) -> str: # type: ignore[misc] return internal # Prune verbose suffixes from translated weapon names _WEAPON_PRUNE = [ " air-to-ground missiles", " air-to-ground missile", " air-to-air missiles", " air-to-air missile", " anti-radiation missile", " anti-tank guided missile", " anti-tank guided missiles", " guided bomb", " guided bombs", ] _orig_translate_weapon = _translate_weapon def _translate_weapon(internal: str) -> str: # type: ignore[misc] # noqa: F811 name = _orig_translate_weapon(internal) for suffix in _WEAPON_PRUNE: if name.endswith(suffix): name = name[:-len(suffix)] break return name # Load .env from repo root _env_path = Path(__file__).parent.parent / ".env" if _env_path.exists(): for _line in _env_path.read_text().splitlines(): if "=" in _line and not _line.startswith("#"): _k, _, _v = _line.partition("=") os.environ.setdefault(_k.strip(), _v.strip()) # ── Config ───────────────────────────────────────────────────────────────────── CANVAS_MIN = 1024 # Minimum canvas resolution (px) CANVAS_MAX = 4096 # Maximum canvas resolution (px) MIN_OUTPUT = 1024 # Target minimum crop output size (px) FPS = 24 # Video frames per second SPEED = 4 # Playback speed multiplier (4× = 1min sim → 15s video) TRAIL_MS = 18_000 # Ground trail length (ms, video time) AIR_TRAIL_MS = 4_000 # Aircraft trail length (ms, video time) DRONE_TRAIL_MS = 2_000 # Drone trail length (ms, video time) DOT_R = 5 # Player dot radius (px) DRONE_R = 3 # Drone dot radius (px) AIR_R = 4 # Aircraft triangle half-size (px) TANK_ICON_SIZE = 15 # Ground vehicle icon size (px, at 1024px reference) AIR_ICON_SIZE = 20 # Aircraft icon size (px, at 1024px reference) ICON_HIGHLIGHT_PAD = 3 # Highlight border padding around icon (px, at 1024px reference) ICON_HIGHLIGHT_COLOR = (200, 200, 200, 120) # RGBA highlight backdrop color KILL_TTL = 4_000 # Kill marker display duration (ms, video time) GHOST_TTL = 3_000 # Death fade-to-black duration (ms, video time) WIPE_GRACE = 90 # Extra frames rendered after a team wipe N_WORKERS = min(8, os.cpu_count() or 4) # Thread pool size for parallel frame rendering WIN_COLOR = (0, 200, 0) # green LOSE_COLOR = (220, 30, 30) # red MINIMAPS_DIR = _SHARED_DIR / "MAPS" / "MINIMAPS" LEVELS_DIR = _SHARED_DIR / "MAPS" / "LEVELS" ICONS_DIR = _SHARED_DIR / "ICONS" _tl = threading.local() # ── Vehicle-type icon system ────────────────────────────────────────────────── # Map raw unittags tags → icon key (checked in order, first match wins) _TAG_TO_ICON = { "type_spaa": "spaa", "type_light_tank": "light_tank", "type_tank_destroyer": "tank_destroyer", "type_heavy_tank": "heavy_tank", "type_medium_tank": "medium_tank", "type_missile_tank": "tank_destroyer", "type_jet_bomber": "jet_bomber", "type_bomber": "bomber", "type_strike_aircraft": "fighter", "type_jet_fighter": "jet", "type_fighter": "fighter", "type_strike_ucav": "drone", "type_helicopter": "helicopter", } # Icon key → PNG filename (relative to ICONS_DIR) _ICON_FILES = { "light_tank": "light.png", "medium_tank": "medium.png", "heavy_tank": "heavy.png", "tank_destroyer": "tank_destroyer.png", "spaa": "spaa.png", "fighter": "FALLBACKS/fighter_icon.png", "jet": "FALLBACKS/jet_icon.png", "jet_bomber": "FALLBACKS/jet_bomber_icon.png", "bomber": "FALLBACKS/bomber_icon.png", "drone": "drone.png", "helicopter": "FALLBACKS/helicopter_icon.png", "unknown": "tank_icon.png", } # ── Premultiplied sprite ─────────────────────────────────────────────────────── @dataclass class Sprite: """Premultiplied RGBA sprite for fast alpha compositing onto RGB buffers. Attributes: pm: Premultiplied RGB array, shape (h, w, 3), uint8. ia: Inverse alpha array, shape (h, w, 1), uint16. h: Sprite height in pixels. w: Sprite width in pixels. """ pm: np.ndarray # (h, w, 3) uint8 premultiplied RGB ia: np.ndarray # (h, w, 1) uint16 inverse alpha, pre-cast h: int w: int def make_sprite(rgba: np.ndarray) -> Sprite: a = rgba[..., 3:4].astype(np.uint16) pm = ((rgba[..., :3].astype(np.uint16) * a) >> 8).astype(np.uint8) ia = (255 - rgba[..., 3:4]).astype(np.uint16) return Sprite(pm=pm, ia=ia, h=rgba.shape[0], w=rgba.shape[1]) def make_black_sprite(spr: Sprite) -> Sprite: """Create a black version of a sprite (RGB=0, same alpha).""" pm = np.zeros_like(spr.pm) ia = np.full_like(spr.ia, 255, dtype=np.uint16) # Reconstruct alpha from inverse alpha, then rebuild alpha = 255 - spr.ia.astype(np.int16) ia = (255 - alpha).astype(np.uint16) return Sprite(pm=pm, ia=ia, h=spr.h, w=spr.w) def make_bare_black_sprite(icon_key: str, size: int, highlight_pad: int) -> Sprite: """Create a black icon sprite with no highlight glow — just the bare silhouette. The sprite is sized to match the highlighted version (with padding) so it can be used as a drop-in replacement when the highlight fades away. """ rgba = _load_icon_rgba(icon_key) src_h, src_w = rgba.shape[:2] scale_f = size / max(src_h, src_w) new_w = max(1, int(src_w * scale_f + 0.5)) new_h = max(1, int(src_h * scale_f + 0.5)) img = Image.fromarray(rgba).resize((new_w, new_h), Image.Resampling.LANCZOS) arr = np.asarray(img).copy() # Zero out RGB, keep alpha — black silhouette arr[..., :3] = 0 if highlight_pad > 0: # Embed in padded canvas to match highlighted sprite dimensions out_h, out_w = new_h + highlight_pad * 2, new_w + highlight_pad * 2 canvas = np.zeros((out_h, out_w, 4), dtype=np.uint8) canvas[highlight_pad:highlight_pad + new_h, highlight_pad:highlight_pad + new_w] = arr arr = canvas return make_sprite(arr) def _get_unit_tags(internal_name: str) -> list[str] | None: """Get raw tags for a vehicle from UnitTags, returns None if not found.""" from data_parser import UnitTags return UnitTags.get()._get_tags(internal_name) MINIS_DIR = _SHARED_DIR / "ICONS" / "MINIS" _AIRCRAFT_TAGS = {"air", "aircraft", "helicopter"} def get_icon_key(model_name: str) -> str: """Map a ModelName (e.g. 'tankModels/ussr_t_34') to an icon key. For aircraft/helicopters, tries a per-vehicle mini icon first ('{internal}_ico.png' in MINIS/), then falls back to tag-based. Ground vehicles always use tag-based icons. """ internal = model_name.split("/")[-1] tags = _get_unit_tags(internal) if tags: tag_set = set(tags) # Only aircraft/helis get per-vehicle mini icons if tag_set & _AIRCRAFT_TAGS: mini_path = MINIS_DIR / f"{internal}_ico.png" if mini_path.exists(): return f"mini:{internal}" # Tag-based fallback (used for all ground vehicles, and aircraft without a mini) for tag, icon in _TAG_TO_ICON.items(): if tag in tag_set: return icon return "unknown" @lru_cache(maxsize=512) def _load_icon_rgba(icon_key: str) -> np.ndarray: """Load an icon PNG as RGBA numpy array, cropped to content bounds. Cached.""" if icon_key.startswith("mini:"): internal = icon_key[5:] path = MINIS_DIR / f"{internal}_ico.png" else: filename = _ICON_FILES.get(icon_key, _ICON_FILES["unknown"]) path = ICONS_DIR / filename arr = np.asarray(Image.open(path).convert("RGBA")).copy() # Crop to bounding box of non-transparent pixels alpha = arr[..., 3] rows = np.any(alpha > 0, axis=1) cols = np.any(alpha > 0, axis=0) if rows.any() and cols.any(): y0, y1 = np.where(rows)[0][[0, -1]] x0, x1 = np.where(cols)[0][[0, -1]] arr = arr[y0:y1 + 1, x0:x1 + 1].copy() return arr def make_tinted_icon_sprite(icon_key: str, color: tuple[int, int, int], size: int, highlight_pad: int = 0, highlight_color: tuple[int, int, int, int] = (0, 0, 0, 0), ) -> Sprite: """Load an icon, resize preserving aspect ratio, tint, add outline highlight.""" rgba = _load_icon_rgba(icon_key) src_h, src_w = rgba.shape[:2] # Resize to fit within `size` height, preserving aspect ratio scale_f = size / max(src_h, src_w) new_w = max(1, int(src_w * scale_f + 0.5)) new_h = max(1, int(src_h * scale_f + 0.5)) img = Image.fromarray(rgba).resize((new_w, new_h), Image.Resampling.LANCZOS) arr = np.asarray(img).copy().astype(np.float32) # Tint: multiply RGB by color/255, preserving alpha arr[..., 0] *= color[0] / 255.0 arr[..., 1] *= color[1] / 255.0 arr[..., 2] *= color[2] / 255.0 tinted = np.clip(arr, 0, 255).astype(np.uint8) if highlight_pad <= 0 or highlight_color[3] == 0: return make_sprite(tinted) # Build outline highlight by painting the highlight color at offsets around the icon pad = highlight_pad out_h, out_w = new_h + pad * 2, new_w + pad * 2 canvas = np.zeros((out_h, out_w, 4), dtype=np.uint8) tinted_img = Image.fromarray(tinted) hl_layer = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 0)) # Stamp the icon alpha at each offset around the center to create an outline for dy in range(-pad, pad + 1): for dx in range(-pad, pad + 1): if dx * dx + dy * dy > pad * pad: continue hl_layer.paste(tinted_img, (pad + dx, pad + dy), tinted_img) # Replace RGB with highlight color, keep the merged alpha capped hl_arr = np.asarray(hl_layer).copy() mask = hl_arr[..., 3] > 0 hl_arr[mask, 0] = highlight_color[0] hl_arr[mask, 1] = highlight_color[1] hl_arr[mask, 2] = highlight_color[2] hl_arr[..., 3] = np.minimum(hl_arr[..., 3], highlight_color[3]) # Composite tinted icon on top of highlight bg = Image.fromarray(hl_arr) bg.paste(tinted_img, (pad, pad), tinted_img) return make_sprite(np.asarray(bg).copy()) def load_target_sprite(filename: str, size: int) -> Sprite: """Load a target icon PNG, resize, return as Sprite.""" path = ICONS_DIR / filename rgba = np.asarray(Image.open(path).convert("RGBA").resize( (size, size), Image.Resampling.LANCZOS )).copy() return make_sprite(rgba) # ── Rotated sprite cache for aircraft ───────────────────────────────────────── ROTATION_STEPS = 72 # one sprite every 5° def _sprite_to_rgba(spr: Sprite) -> np.ndarray: """Reconstruct RGBA array from a premultiplied Sprite.""" alpha = (255 - spr.ia[..., 0]).astype(np.uint8) rgba = np.zeros((spr.h, spr.w, 4), dtype=np.uint8) safe_a = np.maximum(alpha, 1).astype(np.float32) rgba[..., 0] = np.clip(spr.pm[..., 0].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8) rgba[..., 1] = np.clip(spr.pm[..., 1].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8) rgba[..., 2] = np.clip(spr.pm[..., 2].astype(np.float32) * 255.0 / safe_a, 0, 255).astype(np.uint8) rgba[..., 3] = alpha return rgba def make_rotation_cache(spr: Sprite) -> list[Sprite]: """Pre-compute rotated sprites at ROTATION_STEPS even angles. Index 0 = 0°, etc.""" rgba = _sprite_to_rgba(spr) img = Image.fromarray(rgba) cache: list[Sprite] = [] for i in range(ROTATION_STEPS): deg = i * (360.0 / ROTATION_STEPS) # PIL rotates CCW; we want CW rotation for heading, so negate rot = img.rotate(-deg, resample=Image.Resampling.BILINEAR, expand=True) cache.append(make_sprite(np.asarray(rot).copy())) return cache def precompute_headings(px: np.ndarray, py: np.ndarray) -> np.ndarray: """Compute heading angle (degrees, 0=up/north, CW) per entity per frame. Returns (n_entities, n_frames) float32 array. -1 where invalid. """ n_ents, n_frames = px.shape headings = np.full((n_ents, n_frames), -1.0, dtype=np.float32) for i in range(n_ents): last_heading = 0.0 for f in range(1, n_frames): if px[i, f] < 0 or px[i, f - 1] < 0: headings[i, f] = last_heading continue dx = float(px[i, f] - px[i, f - 1]) dy = float(py[i, f] - py[i, f - 1]) if abs(dx) < 0.5 and abs(dy) < 0.5: headings[i, f] = last_heading continue # atan2(dx, -dy): 0=up, 90=right, 180=down, 270=left deg = math.degrees(math.atan2(dx, -dy)) % 360 last_heading = deg headings[i, f] = deg # Fill frame 0 with frame 1's heading if n_frames > 1: headings[i, 0] = headings[i, 1] return headings def heading_to_rot_index(deg: float) -> int: """Convert a heading in degrees to a rotation cache index.""" return int(round(deg / (360.0 / ROTATION_STEPS))) % ROTATION_STEPS def blit(buf: np.ndarray, spr: Sprite, x: int, y: int) -> None: x1, y1 = max(x, 0), max(y, 0) H, W = buf.shape[0], buf.shape[1] x2, y2 = min(x + spr.w, W), min(y + spr.h, H) if x1 >= x2 or y1 >= y2: return sy1, sx1 = y1 - y, x1 - x sy2, sx2 = sy1 + (y2 - y1), sx1 + (x2 - x1) pm = spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) buf[y1:y2, x1:x2] = (pm + ((buf[y1:y2, x1:x2] * spr.ia[sy1:sy2, sx1:sx2]) >> 8) ).astype(np.uint8) def blit_alpha(buf: np.ndarray, spr: Sprite, x: int, y: int, alpha: float) -> None: """Like blit() but with an additional [0,1] alpha multiplier.""" if alpha <= 0: return x1, y1 = max(x, 0), max(y, 0) H, W = buf.shape[0], buf.shape[1] x2, y2 = min(x + spr.w, W), min(y + spr.h, H) if x1 >= x2 or y1 >= y2: return sy1, sx1 = y1 - y, x1 - x sy2, sx2 = sy1 + (y2 - y1), sx1 + (x2 - x1) a16 = int(alpha * 256) pm = ((spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) * a16) >> 8).astype(np.uint16) ia = 255 - (((255 - spr.ia[sy1:sy2, sx1:sx2].astype(np.uint16)) * a16) >> 8) buf[y1:y2, x1:x2] = (pm + ((buf[y1:y2, x1:x2] * ia) >> 8)).astype(np.uint8) def blit_batch(buf: np.ndarray, items: list[tuple[Sprite, int, int]]) -> None: """Blit multiple sprites in one call — avoids per-call Python/numpy overhead.""" H = buf.shape[0] W = buf.shape[1] for spr, x, y in items: x1 = max(x, 0); y1 = max(y, 0) x2 = min(x + spr.w, W); y2 = min(y + spr.h, H) if x1 >= x2 or y1 >= y2: continue sy1 = y1 - y; sx1 = x1 - x sy2 = sy1 + (y2 - y1); sx2 = sx1 + (x2 - x1) pm = spr.pm[sy1:sy2, sx1:sx2].astype(np.uint16) buf[y1:y2, x1:x2] = (pm + ((buf[y1:y2, x1:x2] * spr.ia[sy1:sy2, sx1:sx2]) >> 8) ).astype(np.uint8) # ── VideoCtx — all pre-computed data for one output video ───────────────────── @dataclass class VideoCtx: """Pre-computed rendering context for one GOB replay video. Holds all interpolated positions, colors, death states, kill/damage events, label sprites, and the baked background for every frame so that render_one_ctx can draw each frame without re-computing anything. """ n_players: int px_all: np.ndarray # (n_players, n_frames) int16, -1=absent py_all: np.ndarray colors_arr: np.ndarray # (n_players, 3) uint8 trail_colors_arr: np.ndarray # (n_players, 3) uint8 is_dead: np.ndarray # (n_players, n_frames) bool death_frame: np.ndarray # (n_players,) int32 — frame of death, or n_frames death_fade: np.ndarray # (n_players, n_frames) float32 — 1→0 over ghost ttl last_dead_px: np.ndarray # (n_players,) int16 — last known x at death last_dead_py: np.ndarray # (n_players,) int16 — last known y at death kills_by_frame: list # per-frame kill events damages_by_frame: list # per-frame damage events label_sprites: list # list[Sprite] combined name+vehicle per player bg_arr: np.ndarray # pre-baked background RGB end_frame: int n_drones: int px_drone: np.ndarray | None py_drone: np.ndarray | None drone_colors_arr: np.ndarray | None drone_trail_colors: np.ndarray | None drone_sprites: list # list[Sprite] drone_is_hit: np.ndarray | None = None # True from kill onward (for fade) drone_hit_fade: np.ndarray | None = None # 1→0 from kill to crash drone_is_crashed: np.ndarray | None = None # True after path ends drone_crash_frame: np.ndarray | None = None drone_last_dead_px: np.ndarray | None = None drone_last_dead_py: np.ndarray | None = None drone_alt: np.ndarray | None = None drone_alt_sprites: dict = field(default_factory=dict) n_aircraft: int = 0 px_air: np.ndarray | None = None py_air: np.ndarray | None = None air_colors_arr: np.ndarray | None = None air_trail_colors: np.ndarray | None = None air_sprites: list = field(default_factory=list) air_is_hit: np.ndarray | None = None # True from kill onward air_hit_fade: np.ndarray | None = None # 1→0 from kill to crash air_is_crashed: np.ndarray | None = None # True after path ends air_crash_frame: np.ndarray | None = None air_last_dead_px: np.ndarray | None = None air_last_dead_py: np.ndarray | None = None air_alt: np.ndarray | None = None air_alt_sprites: dict = field(default_factory=dict) air_trail_f: int = 1 drone_trail_f: int = 1 # Scaled circle masks dot_dy: np.ndarray = field(default_factory=lambda: DOT_DY) dot_dx: np.ndarray = field(default_factory=lambda: DOT_DX) shadow_dy: np.ndarray = field(default_factory=lambda: SHADOW_DY) shadow_dx: np.ndarray = field(default_factory=lambda: SHADOW_DX) drone_dy: np.ndarray = field(default_factory=lambda: DRONE_DY) drone_dx: np.ndarray = field(default_factory=lambda: DRONE_DX) air_dy: np.ndarray = field(default_factory=lambda: AIR_DY) air_dx: np.ndarray = field(default_factory=lambda: AIR_DX) dot_r: int = DOT_R drone_r: int = DRONE_R air_r: int = AIR_R canvas: int = CANVAS_MIN # Per-player vehicle icon sprites (tinted to player color) player_icon_sprites: list = field(default_factory=list) # list[Sprite], len=n_players player_dead_sprites: list = field(default_factory=list) # list[Sprite], black versions air_icon_sprites: list = field(default_factory=list) # list[Sprite], len=n_aircraft air_dead_sprites: list = field(default_factory=list) # list[Sprite], black versions # Per-aircraft rotation caches and heading angles air_rot_caches: list = field(default_factory=list) # list[list[Sprite]], per-aircraft air_dead_rot_caches: list = field(default_factory=list) # list[list[Sprite]], black versions air_headings: np.ndarray | None = None # (n_aircraft, n_frames) float32 # Per-drone icon sprites + rotation drone_icon_sprites: list = field(default_factory=list) drone_dead_sprites: list = field(default_factory=list) drone_rot_caches: list = field(default_factory=list) drone_dead_rot_caches: list = field(default_factory=list) drone_headings: np.ndarray | None = None # Kill/damage target icons kill_target_spr: Sprite | None = None dmg_target_spr: Sprite | None = None # ── Map load ─────────────────────────────────────────────────────────────────── NANACHI_ASSETS = "https://thunder.nanachi.party/assets/maps" NANACHI_API_URL = "https://thunder.nanachi.party" NANACHI_VISUAL_TOKEN = os.getenv("NANACHI_VISUAL_TOKEN", "") def load_map_image(level_path: str, canvas: int = CANVAS_MIN) -> Image.Image | None: """Load tankmap PNG from local MINIMAPS, falling back to Nanachi CDN.""" stem = Path(level_path).stem # e.g. "avg_ardennes_snow" path = MINIMAPS_DIR / (stem + "_tankmap.png") if not path.exists(): url = f"{NANACHI_ASSETS}/tankmap/full/{level_path}" print(f" Not local, downloading from Nanachi …", end=" ", flush=True) try: urllib.request.urlretrieve(url, path) print("ok") except Exception as e: print(f"failed ({e})") return None return Image.open(path).convert("RGB").resize( (canvas, canvas), Image.Resampling.LANCZOS ) # ── Coordinate transform ─────────────────────────────────────────────────────── def _fetch_nanachi_level_def(session_id: int) -> dict | None: """Fallback: fetch LevelDef from Nanachi API.""" auth = f"Bearer {NANACHI_VISUAL_TOKEN}" url = f"{NANACHI_API_URL}/api/0/visuals/session/{session_id}/interactive" req = urllib.request.Request(url, headers={"Authorization": auth}) try: with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) return data.get("LevelDef") except Exception as e: print(f"failed ({e})") return None def load_level_coords(level_path: str, session_id: int = 0) -> dict | None: """Load tankMapCoord0/1 from local .blkx, falling back to Nanachi API.""" stem = Path(level_path).stem blkx = LEVELS_DIR / (stem + ".blkx") if blkx.exists(): try: data = json.loads(blkx.read_text()) if "tankMapCoord0" in data and "tankMapCoord1" in data: return data except Exception as e: print(f" Failed to parse level def: {e}") # Fallback to Nanachi API if session_id: print("not local, trying Nanachi …", end=" ", flush=True) ld = _fetch_nanachi_level_def(session_id) if ld and "tankMapCoord0" in ld and "tankMapCoord1" in ld: return ld return None class CoordTransform: """Transforms world coordinates (X, Z) to pixel coordinates on the canvas. Args: x0: World X origin (left edge of the map). z0: World Z origin (bottom edge of the map). x1: World X extent (right edge of the map). z1: World Z extent (top edge of the map). canvas: Canvas size in pixels (square). """ def __init__(self, x0: float = 0, z0: float = 0, x1: float = 4096, z1: float = 4096, canvas: int = CANVAS_MIN): self.x0 = x0 self.z0 = z0 self.x_range = x1 - x0 self.z_range = z1 - z0 self.canvas = canvas def world_to_px(self, x: np.ndarray, z: np.ndarray): """Convert world X/Z arrays to pixel coordinates on the canvas. Args: x: World X positions as a numpy array. z: World Z positions as a numpy array. Returns: Tuple of (px, py) int16 numpy arrays in pixel space. """ px = ((x - self.x0) / self.x_range * self.canvas).astype(np.int16) py = ((self.z0 + self.z_range - z) / self.z_range * self.canvas).astype(np.int16) return px, py def point(self, x: float, z: float) -> tuple[int, int]: xi, zi = self.world_to_px(np.array([x]), np.array([z])) return int(np.clip(xi[0], 0, self.canvas - 1)), int(np.clip(zi[0], 0, self.canvas - 1)) # ── Pre-computation ──────────────────────────────────────────────────────────── def precompute_positions(players: list[dict], xfm: CoordTransform, frame_times: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Interpolate player world positions onto the frame time grid and convert to pixels. Args: players: List of player dicts, each containing a '_samples' key with time-stamped X/Z path data. xfm: Coordinate transform for world-to-pixel conversion. frame_times: 1-D array of frame timestamps in milliseconds. Returns: Tuple of (px_all, py_all) int16 arrays, shape (n_players, n_frames). -1 indicates the player is absent at that frame. """ n_p, n_f = len(players), len(frame_times) px_all = np.full((n_p, n_f), -1, dtype=np.int16) py_all = np.full((n_p, n_f), -1, dtype=np.int16) for i, p in enumerate(players): s = p["_samples"] t = np.array([x["Time"] for x in s], dtype=np.float64) xa = np.array([x["X"] for x in s], dtype=np.float64) za = np.array([x["Z"] for x in s], dtype=np.float64) mask = (frame_times >= t[0]) & (frame_times <= t[-1]) if not mask.any(): continue xi, zi = np.interp(frame_times[mask], t, xa), np.interp(frame_times[mask], t, za) pxi, pyi = xfm.world_to_px(xi, zi) in_bounds = (pxi >= 0) & (pxi < xfm.canvas) & (pyi >= 0) & (pyi < xfm.canvas) full_mask = np.where(mask)[0][in_bounds] px_all[i, full_mask] = pxi[in_bounds] py_all[i, full_mask] = pyi[in_bounds] return px_all, py_all def precompute_altitudes(players: list[dict], frame_times: np.ndarray) -> np.ndarray: """Return (n_players, n_frames) int16 array of altitude in metres. -1 = absent.""" n_p, n_f = len(players), len(frame_times) alt_all = np.full((n_p, n_f), -1, dtype=np.int16) for i, p in enumerate(players): s = p["_samples"] t = np.array([x["Time"] for x in s], dtype=np.float64) ya = np.array([x["Y"] for x in s], dtype=np.float64) mask = (frame_times >= t[0]) & (frame_times <= t[-1]) if not mask.any(): continue yi = np.interp(frame_times[mask], t, ya) alt_all[i, mask] = np.clip(yi, 0, 32767).astype(np.int16) return alt_all def precompute_kills(kills: list[dict], xfm: CoordTransform, t_start: float, ms_per_frame: float, n_frames: int, fps: int, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, offset_x: int = 0, offset_y: int = 0, pid_pos: dict[int, tuple[np.ndarray, np.ndarray]] | None = None, ) -> list[list[tuple]]: """ Returns per-frame list of tuples: (vx, vy, kx, ky, age_frac, label_sprite | None) label_sprite is shown for the first half of the kill TTL then fades out. offset_x/y: crop origin to shift coordinates into crop-space. pid_pos: optional {PlayerID: (px_arr, py_arr)} for tracking moving entities. When provided, kill lines follow the entity's interpolated position each frame instead of using the static GOB snapshot position. """ out: list[list[tuple]] = [[] for _ in range(n_frames)] kill_f = int(math.ceil(KILL_TTL * fps / 1000.0)) for k in kills: vp = k.get("VictimPosition") if not vp: continue kf = int((k["Time"] - t_start) / ms_per_frame) if not (0 <= kf < n_frames): continue # Static fallback positions from the kill event svx, svy = xfm.point(vp["X"], vp["Z"]) svx -= offset_x; svy -= offset_y kp = k.get("KillerPosition") if kp: skx, sky = xfm.point(kp["X"], kp["Z"]) skx -= offset_x; sky -= offset_y else: skx, sky = -1, -1 # Look up tracked position arrays for killer/victim kid = k.get("KillerID", 0) vid = k.get("VictimID", 0) k_tracked = pid_pos.get(kid) if pid_pos and kid else None v_tracked = pid_pos.get(vid) if pid_pos and vid else None killer_model = k.get("KillerModel", "") weapon = k.get("Weapon", "") label = make_kill_label(killer_model, weapon, font) if killer_model else None for df in range(kill_f): f = kf + df if f >= n_frames: break # Use tracked position if available and valid at this frame if v_tracked is not None and int(v_tracked[0][f]) >= 0: vx, vy = int(v_tracked[0][f]), int(v_tracked[1][f]) else: vx, vy = svx, svy if k_tracked is not None and int(k_tracked[0][f]) >= 0: kx, ky = int(k_tracked[0][f]), int(k_tracked[1][f]) else: kx, ky = skx, sky out[f].append((vx, vy, kx, ky, df / kill_f, label)) return out DMG_TTL = 2_000 # Damage line display duration (ms, video time) def precompute_damages(damages: list[dict], active: list[dict], px_all: np.ndarray, py_all: np.ndarray, t_start: float, ms_per_frame: float, n_frames: int, fps: int, ) -> list[list[tuple]]: """Build per-frame damage line events from raw damage reports. Each tuple is (ox, oy, vx, vy, age_frac) where positions are looked up from px_all/py_all at the damage time. Args: damages: Raw damage report dicts from the GOB replay. active: Active player dicts (used for PlayerID-to-index mapping). px_all: Precomputed pixel X positions, shape (n_players, n_frames). py_all: Precomputed pixel Y positions, shape (n_players, n_frames). t_start: Replay start time in milliseconds. ms_per_frame: Milliseconds per video frame. n_frames: Total number of video frames. fps: Frames per second. Returns: List of lists, one per frame, containing damage line tuples. """ pid_to_idx = {p["PlayerID"]: i for i, p in enumerate(active)} out: list[list[tuple]] = [[] for _ in range(n_frames)] dmg_f = int(math.ceil(DMG_TTL * fps / 1000.0)) for dr in damages: off_id = dr.get("OffenderID", 0) vic_id = dr.get("OffendedID", 0) if off_id not in pid_to_idx or vic_id not in pid_to_idx: continue oi = pid_to_idx[off_id] vi = pid_to_idx[vic_id] kf = int((dr["Time"] - t_start) / ms_per_frame) if not (0 <= kf < n_frames): continue ox, oy = int(px_all[oi, kf]), int(py_all[oi, kf]) vx, vy = int(px_all[vi, kf]), int(py_all[vi, kf]) if ox < 0 or oy < 0 or vx < 0 or vy < 0: continue for df in range(dmg_f): f = kf + df if f >= n_frames: break out[f].append((ox, oy, vx, vy, df / dmg_f)) return out def precompute_deaths(active: list[dict], kills: list[dict], t_start: float, ms_per_frame: float, n_frames: int, fps: int, px_all: np.ndarray, py_all: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Compute per-player death state, fade-to-black curve, and last known position. Args: active: Active ground player dicts. kills: Kill event dicts from the replay. t_start: Replay start time in ms. ms_per_frame: Milliseconds per video frame. n_frames: Total video frames. fps: Frames per second. px_all: Precomputed pixel X positions. py_all: Precomputed pixel Y positions. Returns: Tuple of (is_dead, death_frame, death_fade, last_dead_px, last_dead_py). """ pid_to_idx = {p["PlayerID"]: i for i, p in enumerate(active)} n_p = len(active) ghost_f = max(1, int(GHOST_TTL * fps / 1000.0)) is_dead = np.zeros((n_p, n_frames), dtype=bool) death_frame = np.full(n_p, n_frames, dtype=np.int32) death_fade = np.zeros((n_p, n_frames), dtype=np.float32) last_dead_px = np.full(n_p, -1, dtype=np.int16) last_dead_py = np.full(n_p, -1, dtype=np.int16) for k in kills: vid = k.get("VictimID", 0) if vid not in pid_to_idx: continue idx = pid_to_idx[vid] kf = int((k["Time"] - t_start) / ms_per_frame) if not (0 <= kf < n_frames): continue is_dead[idx, kf + 1:] = True death_frame[idx] = kf # Find last valid position at or before death for f in range(kf, -1, -1): if px_all[idx, f] >= 0: last_dead_px[idx] = px_all[idx, f] last_dead_py[idx] = py_all[idx, f] break # Fade from 1.0 (full color) to 0.0 (black) over ghost_f video frames fade_len = min(ghost_f, n_frames - kf - 1) death_fade[idx, kf + 1:kf + 1 + fade_len] = np.maximum( 0.0, 1.0 - np.arange(1, fade_len + 1) / ghost_f ) # After fade completes, stays 0.0 (black) return is_dead, death_frame, death_fade, last_dead_px, last_dead_py def precompute_air_deaths(active: list[dict], kills: list[dict], t_start: float, ms_per_frame: float, n_frames: int, fps: int, px_all: np.ndarray, py_all: np.ndarray, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Death state for aircraft/drones: they keep moving after kill until path ends (crash). Returns: is_hit: (n, n_frames) bool — True from kill onward (for color fade) hit_fade: (n, n_frames) float32 — 1→0 from kill to crash is_crashed: (n, n_frames) bool — True only after path data ends crash_frame: (n,) int32 — frame when path ends last_px/py: (n,) int16 — position at crash (last valid path point) """ pid_to_idx: dict[int, int] = {} eidx_to_idx: dict[int, int] = {} for i, p in enumerate(active): pid = p.get("PlayerID", 0) if pid: pid_to_idx[pid] = i eidx = p.get("EntityIndex", 0) if eidx: eidx_to_idx[eidx] = i n_p = len(active) is_hit = np.zeros((n_p, n_frames), dtype=bool) hit_fade = np.ones((n_p, n_frames), dtype=np.float32) is_crashed = np.zeros((n_p, n_frames), dtype=bool) crash_frame = np.full(n_p, n_frames, dtype=np.int32) last_px = np.full(n_p, -1, dtype=np.int16) last_py = np.full(n_p, -1, dtype=np.int16) # Find last valid position frame for each entity for i in range(n_p): for f in range(n_frames - 1, -1, -1): if px_all[i, f] >= 0: crash_frame[i] = f last_px[i] = px_all[i, f] last_py[i] = py_all[i, f] break # Mark crashed after last valid frame cf = int(crash_frame[i]) if cf < n_frames - 1: is_crashed[i, cf + 1:] = True ghost_f = max(1, int(GHOST_TTL * fps / 1000.0)) hit_set: set[int] = set() for k in kills: # Match by PlayerID first, then by EntityIndex (for drones with PlayerID=0) vid = k.get("VictimID", 0) idx = pid_to_idx.get(vid) if idx is None: veidx = k.get("VictimEntityIndex", 0) idx = eidx_to_idx.get(veidx) if idx is None: continue kf = int((k["Time"] - t_start) / ms_per_frame) if not (0 <= kf < n_frames): continue hit_set.add(idx) is_hit[idx, kf:] = True # Fade from 1.0 → 0.0 between kill frame and crash frame cf = int(crash_frame[idx]) fade_len = cf - kf if fade_len > 0: n_slots = min(fade_len + 1, n_frames - kf) hit_fade[idx, kf:kf + n_slots] = np.maximum( 0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / fade_len ) hit_fade[idx, cf + 1:] = 0.0 # For entities not matched by any kill (e.g. drones with PlayerID=0), # fade to black over GHOST_TTL before their path ends (crash) for i in range(n_p): if i in hit_set: continue cf = int(crash_frame[i]) if cf >= n_frames: continue # path never ends in this clip fade_start = max(0, cf - ghost_f) fade_len = cf - fade_start if fade_len > 0: is_hit[i, fade_start:] = True n_slots = min(fade_len + 1, n_frames - fade_start) hit_fade[i, fade_start:fade_start + n_slots] = np.maximum( 0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / fade_len ) hit_fade[i, cf + 1:] = 0.0 return is_hit, hit_fade, is_crashed, crash_frame, last_px, last_py def find_team_wipe_frame(active: list[dict], is_dead: np.ndarray, n_frames: int) -> int: """Find the first frame where all players on any one team are dead. Args: active: Active player dicts (must contain a 'Team' key). is_dead: Boolean array, shape (n_players, n_frames). n_frames: Total video frames. Returns: Frame index of the first team wipe, or n_frames if none occurs. """ team_groups: dict[int, list[int]] = {} for i, p in enumerate(active): team_groups.setdefault(p["Team"], []).append(i) for f in range(n_frames): for idxs in team_groups.values(): if idxs and all(is_dead[idx, f] for idx in idxs): return f return n_frames def build_drone_list(d: dict, ground_active: list[dict], xfm: CoordTransform, px_ground: np.ndarray, py_ground: np.ndarray, frame_times: np.ndarray, t_start: float, ms_per_frame: float, colors_arr: np.ndarray) -> list[dict]: n_frames = len(frame_times) drones = [] for e in d["Entities"]: if "ucav" not in e["ModelName"].lower() or not e["Path"]: continue spawn_t = e["Path"][0]["Time"] spawn_f = max(0, min(int((spawn_t - t_start) / ms_per_frame), n_frames - 1)) spx, spy = xfm.point(e["Path"][0]["X"], e["Path"][0]["Z"]) best_idx, best_dist = 0, float("inf") for i in range(len(ground_active)): pxi, pyi = int(px_ground[i, spawn_f]), int(py_ground[i, spawn_f]) if pxi < 0: continue dist = (pxi - spx) ** 2 + (pyi - spy) ** 2 if dist < best_dist: best_dist, best_idx = dist, i color = (colors_arr[best_idx].astype(np.float32) * 0.75).astype(np.uint8) drones.append({"entity": e, "color": color, "_samples": e["Path"], "EntityIndex": e["EntityIndex"]}) return drones # ── Circle masks ─────────────────────────────────────────────────────────────── def make_circle_masks(r: int) -> tuple[np.ndarray, np.ndarray]: y, x = np.ogrid[-r:r+1, -r:r+1] mask = x*x + y*y <= r*r ys, xs = np.where(mask) return (ys - r).astype(np.int32), (xs - r).astype(np.int32) SHADOW_DY, SHADOW_DX = make_circle_masks(DOT_R + 1) DOT_DY, DOT_DX = make_circle_masks(DOT_R) DRONE_DY, DRONE_DX = make_circle_masks(DRONE_R) def make_triangle_masks(r: int) -> tuple[np.ndarray, np.ndarray]: """Downward-pointing triangle of half-size r. Returns (dy, dx) offsets.""" pts = [] for y in range(-r, r + 1): # width narrows linearly from full at top (-r) to point at bottom (+r) half_w = int(r * (r - y) / (2 * r)) if r else 0 for x in range(-half_w, half_w + 1): pts.append((y, x)) if not pts: pts = [(0, 0)] arr = np.array(pts, dtype=np.int32) return arr[:, 0], arr[:, 1] AIR_DY, AIR_DX = make_triangle_masks(AIR_R) _TRAIL_DY = np.array([0, 0, 1, 1], dtype=np.int32) _TRAIL_DX = np.array([0, 1, 0, 1], dtype=np.int32) # ── Sprite factories ─────────────────────────────────────────────────────────── def _render_rgba(w: float, h: float, draw_fn) -> np.ndarray: img = Image.new("RGBA", (int(w), int(h)), (0, 0, 0, 0)) draw_fn(ImageDraw.Draw(img)) return np.asarray(img).copy() def make_name_sprites(names: list[str], font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> list[Sprite]: """Render a list of text strings into premultiplied Sprite objects. Args: names: Text labels to render. font: PIL font used for rendering. Returns: List of Sprite objects, one per name. """ dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) PAD = 3 # padding each side (covers stroke_width=1 + margin) out = [] for name in names: bb = dummy.textbbox((0, 0), name, font=font) w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2 def _draw(d, _n=name, _x=bb[0], _y=bb[1]): d.text((PAD - _x, PAD - _y), _n, font=font, fill=(255, 255, 255, 220), stroke_width=1, stroke_fill=(0, 0, 0, 200)) out.append(make_sprite(_render_rgba(w, h, _draw))) return out def merge_sprites(top: Sprite, bottom: Sprite, gap: int = -2) -> Sprite: """Stack two sprites vertically with a gap, return a single combined sprite.""" w = max(top.w, bottom.w) h = top.h + gap + bottom.h pm = np.zeros((h, w, 3), dtype=np.uint8) ia = np.full((h, w, 1), 255, dtype=np.uint16) # top sprite pm[:top.h, :top.w] = top.pm ia[:top.h, :top.w] = top.ia # bottom sprite y_off = top.h + gap pm[y_off:y_off + bottom.h, :bottom.w] = bottom.pm ia[y_off:y_off + bottom.h, :bottom.w] = bottom.ia return Sprite(pm=pm, ia=ia, h=h, w=w) def _short_model(model_name: str) -> str: """Return human-readable vehicle name, falling back to internal ID.""" internal = model_name.split("/")[-1] return _translate_vehicle(internal) def make_kill_label(killer_model: str, weapon: str, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> Sprite: """Render a kill event label showing the killer's vehicle and weapon. Args: killer_model: Internal model path of the killer vehicle. weapon: Internal weapon identifier. font: PIL font used for rendering. Returns: A Sprite containing the rendered kill label text. """ text = f"{_short_model(killer_model)} [{_translate_weapon(weapon)}]" dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) bb = dummy.textbbox((0, 0), text, font=font) PAD = 3 w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2 def _draw(d, _x=bb[0], _y=bb[1]): d.text((PAD - _x, PAD - _y), text, font=font, fill=(255, 230, 100, 230), stroke_width=1, stroke_fill=(0, 0, 0, 200)) return make_sprite(_render_rgba(w, h, _draw)) def make_hud_sprite(team_won: int, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> Sprite: """Render a HUD overlay sprite displaying which team won. Args: team_won: Winning team index. font: PIL font used for rendering. Returns: A Sprite containing the "Team N wins" text. """ text = f"Team {team_won} wins" dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) bb = dummy.textbbox((0, 0), text, font=font) PAD = 3 w, h = bb[2] - bb[0] + PAD * 2, bb[3] - bb[1] + PAD * 2 def _draw(d, _x=bb[0], _y=bb[1]): d.text((PAD - _x, PAD - _y), text, font=font, fill=(160, 255, 120, 230), stroke_width=1, stroke_fill=(0, 0, 0, 180)) return make_sprite(_render_rgba(w, h, _draw)) def make_time_sprites(max_secs: int, speed: float, font: ImageFont.FreeTypeFont | ImageFont.ImageFont, ) -> dict[int, Sprite]: dummy = ImageDraw.Draw(Image.new("RGBA", (1, 1))) out: dict[int, Sprite] = {} for sec in range(max_secs + 1): m, s = divmod(sec, 60) text = f"{m:02d}:{s:02d} ×{speed:.0f}" bb = dummy.textbbox((0, 0), text, font=font) w, h = bb[2] - bb[0] + 4, bb[3] - bb[1] + 4 def _draw(d, _t=text): d.text((2, 2), _t, font=font, fill=(210, 210, 210, 230)) out[sec] = make_sprite(_render_rgba(w, h, _draw)) return out # ── Drawing helpers ──────────────────────────────────────────────────────────── def draw_all_trails_np(buf: np.ndarray, fi: int, px_all: np.ndarray, py_all: np.ndarray, trail_f: int, trail_colors_arr: np.ndarray, is_dead: np.ndarray, death_frame: np.ndarray, canvas: int = CANVAS_MIN) -> None: n_players = px_all.shape[0] all_vx, all_vy, all_cols = [], [], [] for pi in range(n_players): # Dead players: anchor trail at death frame; alive: current frame if is_dead[pi, fi]: ef = int(death_frame[pi]) else: ef = fi start = max(0, ef - trail_f) pxs = px_all[pi, start:ef + 1] pys = py_all[pi, start:ef + 1] valid = (pxs >= 0) & (pys >= 0) if not valid.any(): continue vx = pxs[valid].astype(np.int32) vy = pys[valid].astype(np.int32) trail_len = ef + 1 - start ti = np.where(valid)[0] bright = ((ti + 1).astype(np.float32) / trail_len) ** 0.3 faded = (trail_colors_arr[pi] * bright[:, None]).astype(np.uint8) all_vx.append(vx) all_vy.append(vy) all_cols.append(faded) if not all_vx: return vx = np.concatenate(all_vx) vy = np.concatenate(all_vy) cols = np.concatenate(all_cols) all_ys = np.clip(vy[:, None] + _TRAIL_DY, 0, canvas - 1).ravel() all_xs = np.clip(vx[:, None] + _TRAIL_DX, 0, canvas - 1).ravel() buf[all_ys, all_xs] = np.repeat(cols, 4, axis=0) def draw_all_dots_np(buf: np.ndarray, fi: int, px_all: np.ndarray, py_all: np.ndarray, colors_arr: np.ndarray, shadow_color: np.ndarray, is_dead: np.ndarray | None = None, shadow_dy: np.ndarray = SHADOW_DY, shadow_dx: np.ndarray = SHADOW_DX, dot_dy: np.ndarray = DOT_DY, dot_dx: np.ndarray = DOT_DX, canvas: int = CANVAS_MIN) -> None: cxs = px_all[:, fi].astype(np.int32) cys = py_all[:, fi].astype(np.int32) valid = (cxs >= 0) & (cys >= 0) if is_dead is not None: valid &= ~is_dead[:, fi] if not valid.any(): return cx = cxs[valid]; cy = cys[valid] sy = np.clip(cy[:, None] + shadow_dy, 0, canvas - 1) sx = np.clip(cx[:, None] + shadow_dx, 0, canvas - 1) buf[sy.ravel(), sx.ravel()] = shadow_color nd = len(dot_dy) dy = np.clip(cy[:, None] + dot_dy, 0, canvas - 1) dx = np.clip(cx[:, None] + dot_dx, 0, canvas - 1) buf[dy.ravel(), dx.ravel()] = np.repeat(colors_arr[valid], nd, axis=0) def draw_dead_dots_np(buf: np.ndarray, fi: int, colors_arr: np.ndarray, is_dead: np.ndarray, death_fade: np.ndarray, last_dead_px: np.ndarray, last_dead_py: np.ndarray, dot_dy: np.ndarray = DOT_DY, dot_dx: np.ndarray = DOT_DX, canvas: int = CANVAS_MIN, ) -> None: """Draw dead player dots: fade from color to black, then stay black.""" dead = is_dead[:, fi] if not dead.any(): return cxs = last_dead_px[dead].astype(np.int32) cys = last_dead_py[dead].astype(np.int32) valid = (cxs >= 0) & (cys >= 0) if not valid.any(): return cx, cy = cxs[valid], cys[valid] fade = death_fade[dead, fi][valid] cols = (colors_arr[dead][valid].astype(np.float32) * fade[:, None]).astype(np.uint8) nd = len(dot_dy) dy = np.clip(cy[:, None] + dot_dy, 0, canvas - 1) dx = np.clip(cx[:, None] + dot_dx, 0, canvas - 1) buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0) def draw_drone_dots_np(buf: np.ndarray, fi: int, px_drone: np.ndarray, py_drone: np.ndarray, drone_colors: np.ndarray, is_crashed: np.ndarray | None = None, hit_fade: np.ndarray | None = None, drone_dy: np.ndarray = DRONE_DY, drone_dx: np.ndarray = DRONE_DX, canvas: int = CANVAS_MIN) -> None: cxs = px_drone[:, fi].astype(np.int32) cys = py_drone[:, fi].astype(np.int32) valid = (cxs >= 0) & (cys >= 0) if is_crashed is not None: valid &= ~is_crashed[:, fi] if not valid.any(): return cx = cxs[valid]; cy = cys[valid] cols = drone_colors[valid] if hit_fade is not None: fade = hit_fade[valid, fi] cols = (cols.astype(np.float32) * fade[:, None]).astype(np.uint8) nd = len(drone_dy) dy = np.clip(cy[:, None] + drone_dy, 0, canvas - 1) dx = np.clip(cx[:, None] + drone_dx, 0, canvas - 1) buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0) def draw_aircraft_np(buf: np.ndarray, fi: int, px_air: np.ndarray, py_air: np.ndarray, air_colors: np.ndarray, is_crashed: np.ndarray | None = None, hit_fade: np.ndarray | None = None, air_dy: np.ndarray = AIR_DY, air_dx: np.ndarray = AIR_DX, canvas: int = CANVAS_MIN) -> None: """Draw aircraft as small triangles, fading to black after hit.""" cxs = px_air[:, fi].astype(np.int32) cys = py_air[:, fi].astype(np.int32) valid = (cxs >= 0) & (cys >= 0) if is_crashed is not None: valid &= ~is_crashed[:, fi] if not valid.any(): return cx = cxs[valid]; cy = cys[valid] cols = air_colors[valid] if hit_fade is not None: fade = hit_fade[valid, fi] cols = (cols.astype(np.float32) * fade[:, None]).astype(np.uint8) nd = len(air_dy) dy = np.clip(cy[:, None] + air_dy, 0, canvas - 1) dx = np.clip(cx[:, None] + air_dx, 0, canvas - 1) buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0) def draw_dead_entities_np(buf: np.ndarray, fi: int, colors_arr: np.ndarray, is_dead: np.ndarray, death_fade: np.ndarray, last_dead_px: np.ndarray, last_dead_py: np.ndarray, mask_dy: np.ndarray, mask_dx: np.ndarray, canvas: int = CANVAS_MIN) -> None: """Draw dead entity markers (circle or triangle) fading from color to black.""" dead = is_dead[:, fi] if not dead.any(): return cxs = last_dead_px[dead].astype(np.int32) cys = last_dead_py[dead].astype(np.int32) valid = (cxs >= 0) & (cys >= 0) if not valid.any(): return cx, cy = cxs[valid], cys[valid] fade = death_fade[dead, fi][valid] cols = (colors_arr[dead][valid].astype(np.float32) * fade[:, None]).astype(np.uint8) nd = len(mask_dy) dy = np.clip(cy[:, None] + mask_dy, 0, canvas - 1) dx = np.clip(cx[:, None] + mask_dx, 0, canvas - 1) buf[dy.ravel(), dx.ravel()] = np.repeat(cols, nd, axis=0) def draw_icon_sprites(buf: np.ndarray, fi: int, px_all: np.ndarray, py_all: np.ndarray, icon_sprites: list, is_dead: np.ndarray | None = None, is_crashed: np.ndarray | None = None, hit_fade: np.ndarray | None = None, rot_caches: list | None = None, headings: np.ndarray | None = None) -> None: """Draw per-entity icon sprites instead of circles/triangles.""" n = px_all.shape[0] for i in range(n): px_i, py_i = int(px_all[i, fi]), int(py_all[i, fi]) if px_i < 0 or py_i < 0: continue if is_dead is not None and is_dead[i, fi]: continue if is_crashed is not None and is_crashed[i, fi]: continue if rot_caches and headings is not None and headings[i, fi] >= 0: spr = rot_caches[i][heading_to_rot_index(headings[i, fi])] else: spr = icon_sprites[i] alpha = 1.0 if hit_fade is not None: alpha = float(hit_fade[i, fi]) if alpha <= 0: continue x, y = px_i - spr.w // 2, py_i - spr.h // 2 if alpha < 1.0: blit_alpha(buf, spr, x, y, alpha) else: blit(buf, spr, x, y) def draw_dead_icon_sprites(buf: np.ndarray, fi: int, icon_sprites: list, dead_sprites: list, is_dead: np.ndarray, death_fade: np.ndarray, last_dead_px: np.ndarray, last_dead_py: np.ndarray, rot_caches: list | None = None, dead_rot_caches: list | None = None, headings: np.ndarray | None = None) -> None: """Draw dead entity icons fading from color to black at their last known position.""" n = len(icon_sprites) for i in range(n): if not is_dead[i, fi]: continue px_i, py_i = int(last_dead_px[i]), int(last_dead_py[i]) if px_i < 0 or py_i < 0: continue # Pick rotated sprite if available (use last valid heading) if rot_caches and dead_rot_caches and headings is not None and headings[i, fi] >= 0: ri = heading_to_rot_index(headings[i, fi]) spr = rot_caches[i][ri] dspr = dead_rot_caches[i][ri] else: spr = icon_sprites[i] dspr = dead_sprites[i] x, y = px_i - spr.w // 2, py_i - spr.h // 2 fade = float(death_fade[i, fi]) blit(buf, dspr, x, y) if fade > 0: blit_alpha(buf, spr, x, y, fade) def draw_air_trails_np(buf: np.ndarray, fi: int, px_all: np.ndarray, py_all: np.ndarray, trail_f: int, trail_colors: np.ndarray, is_crashed: np.ndarray | None, crash_frame: np.ndarray | None, canvas: int = CANVAS_MIN) -> None: """Short trails for aircraft/drones with line interpolation between frames. Anchors at crash_frame once crashed (path ended).""" n_ents = px_all.shape[0] all_vx, all_vy, all_cols = [], [], [] CM = canvas - 1 for ei in range(n_ents): if is_crashed is not None and crash_frame is not None and is_crashed[ei, fi]: ef = int(crash_frame[ei]) else: ef = fi start = max(0, ef - trail_f) pxs = px_all[ei, start:ef + 1] pys = py_all[ei, start:ef + 1] valid = (pxs >= 0) & (pys >= 0) if not valid.any(): continue vx = pxs[valid].astype(np.int32) vy = pys[valid].astype(np.int32) trail_len = ef + 1 - start ti = np.where(valid)[0] # Interpolate lines between consecutive valid points to fill gaps if len(vx) >= 2: seg_vx, seg_vy, seg_bright = [], [], [] for si in range(len(vx) - 1): x0, y0, x1, y1 = vx[si], vy[si], vx[si + 1], vy[si + 1] dist = max(abs(x1 - x0), abs(y1 - y0)) if dist <= 1: seg_vx.append(x0) seg_vy.append(y0) seg_bright.append((ti[si] + 1) / trail_len) else: n_pts = min(dist, 64) # cap to avoid huge arrays t = np.arange(n_pts, dtype=np.float32) / n_pts seg_vx.append(np.clip((x0 + (x1 - x0) * t).astype(np.int32), 0, CM)) seg_vy.append(np.clip((y0 + (y1 - y0) * t).astype(np.int32), 0, CM)) b0 = (ti[si] + 1) / trail_len b1 = (ti[si + 1] + 1) / trail_len seg_bright.append(b0 + (b1 - b0) * t) # Last point seg_vx.append(vx[-1]) seg_vy.append(vy[-1]) seg_bright.append((ti[-1] + 1) / trail_len) vx_interp = np.concatenate([np.atleast_1d(s) for s in seg_vx]) vy_interp = np.concatenate([np.atleast_1d(s) for s in seg_vy]) bright = np.concatenate([np.atleast_1d(s) for s in seg_bright]) else: vx_interp, vy_interp = vx, vy bright = np.array([(ti[0] + 1) / trail_len], dtype=np.float32) bright = bright ** 0.3 faded = (trail_colors[ei] * bright[:, None]).astype(np.uint8) all_vx.append(vx_interp) all_vy.append(vy_interp) all_cols.append(faded) if not all_vx: return vx = np.concatenate(all_vx) vy = np.concatenate(all_vy) cols = np.concatenate(all_cols) all_ys = np.clip(vy[:, None] + _TRAIL_DY, 0, canvas - 1).ravel() all_xs = np.clip(vx[:, None] + _TRAIL_DX, 0, canvas - 1).ravel() buf[all_ys, all_xs] = np.repeat(cols, 4, axis=0) def draw_kill_events(buf: np.ndarray, events: list[tuple], kill_target_spr: Sprite | None = None, canvas: int = CANVAS_MIN) -> None: CM = canvas - 1 full_line = np.array([255, 30, 30], dtype=np.float32) _LINE_OFFSETS = [(0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)] for (vx, vy, kx, ky, age_frac, label) in events: alpha = 1.0 - age_frac # Kill line from killer to victim if kx >= 0 and ky >= 0: pts = max(abs(vx - kx), abs(vy - ky)) + 1 if pts >= 2: t = np.arange(pts, dtype=np.float32) / (pts - 1) cx = (kx + (vx - kx) * t).astype(np.int32) cy = (ky + (vy - ky) * t).astype(np.int32) for odx, ody in _LINE_OFFSETS: xs = np.clip(cx + odx, 0, CM) ys = np.clip(cy + ody, 0, CM) bg = buf[ys, xs].astype(np.float32) buf[ys, xs] = (bg + (full_line - bg) * alpha).astype(np.uint8) # Target icon at victim position (replaces starburst) if kill_target_spr is not None: blit_alpha(buf, kill_target_spr, vx - kill_target_spr.w // 2, vy - kill_target_spr.h // 2, alpha) # Label if label is not None: label_alpha = 1.0 if age_frac < 0.8 else max(0.0, 1.0 - (age_frac - 0.8) / 0.2) blit_alpha(buf, label, vx - label.w // 2, vy - label.h - 14, label_alpha) def draw_damage_events(buf: np.ndarray, events: list[tuple], dmg_target_spr: Sprite | None = None, canvas: int = CANVAS_MIN) -> None: CM = canvas - 1 full_line = np.array([255, 255, 80], dtype=np.float32) for (ox, oy, vx, vy, age_frac) in events: alpha = 1.0 - age_frac pts = max(abs(vx - ox), abs(vy - oy)) + 1 if pts >= 2: t = np.arange(pts, dtype=np.float32) / (pts - 1) xs = np.clip((ox + (vx - ox) * t).astype(np.int32), 0, CM) ys = np.clip((oy + (vy - oy) * t).astype(np.int32), 0, CM) bg = buf[ys, xs].astype(np.float32) buf[ys, xs] = (bg + (full_line - bg) * alpha).astype(np.uint8) # Target icon at victim position if dmg_target_spr is not None: blit_alpha(buf, dmg_target_spr, vx - dmg_target_spr.w // 2, vy - dmg_target_spr.h // 2, alpha) # ── Per-frame render for one VideoCtx ───────────────────────────────────────── def render_one_ctx(fi: int, buf: np.ndarray, ctx: VideoCtx, shadow_color: np.ndarray, trail_f: int) -> None: """Render a single video frame into the provided buffer. Draws background, trails, damage/kill events, dots (alive + dead), drones, aircraft, and player name labels in compositing order. Args: fi: Frame index to render. buf: Mutable RGB numpy array, shape (canvas, canvas, 3). ctx: Pre-computed VideoCtx with all per-frame data. shadow_color: RGB color for dot shadows, shape (3,). trail_f: Number of trailing frames to draw behind each player. """ np.copyto(buf, ctx.bg_arr) cv = ctx.canvas # Ground trails draw_all_trails_np(buf, fi, ctx.px_all, ctx.py_all, trail_f, ctx.trail_colors_arr, ctx.is_dead, ctx.death_frame, cv) # Drone trails (very short, line-interpolated) if ctx.n_drones and ctx.drone_trail_colors is not None: assert ctx.px_drone is not None and ctx.py_drone is not None draw_air_trails_np(buf, fi, ctx.px_drone, ctx.py_drone, ctx.drone_trail_f, ctx.drone_trail_colors, ctx.drone_is_crashed, ctx.drone_crash_frame, cv) # Aircraft trails (short, line-interpolated) if ctx.n_aircraft and ctx.air_trail_colors is not None: assert ctx.px_air is not None and ctx.py_air is not None draw_air_trails_np(buf, fi, ctx.px_air, ctx.py_air, ctx.air_trail_f, ctx.air_trail_colors, ctx.air_is_crashed, ctx.air_crash_frame, cv) if ctx.damages_by_frame[fi]: draw_damage_events(buf, ctx.damages_by_frame[fi], ctx.dmg_target_spr, cv) if ctx.kills_by_frame[fi]: draw_kill_events(buf, ctx.kills_by_frame[fi], ctx.kill_target_spr, cv) # Ground: dead icons + alive icons if ctx.player_icon_sprites: draw_dead_icon_sprites(buf, fi, ctx.player_icon_sprites, ctx.player_dead_sprites, ctx.is_dead, ctx.death_fade, ctx.last_dead_px, ctx.last_dead_py) draw_icon_sprites(buf, fi, ctx.px_all, ctx.py_all, ctx.player_icon_sprites, is_dead=ctx.is_dead) else: draw_dead_dots_np(buf, fi, ctx.colors_arr, ctx.is_dead, ctx.death_fade, ctx.last_dead_px, ctx.last_dead_py, ctx.dot_dy, ctx.dot_dx, cv) draw_all_dots_np(buf, fi, ctx.px_all, ctx.py_all, ctx.colors_arr, shadow_color, ctx.is_dead, ctx.shadow_dy, ctx.shadow_dx, ctx.dot_dy, ctx.dot_dx, cv) # Drones: freeze at hit position, fade to transparent over ~2-3s if ctx.n_drones: assert ctx.px_drone is not None and ctx.py_drone is not None assert ctx.drone_colors_arr is not None if ctx.drone_icon_sprites: n_dr = ctx.px_drone.shape[0] for di in range(n_dr): is_hit = ctx.drone_is_hit is not None and ctx.drone_is_hit[di, fi] if is_hit: # Frozen at hit position, fading out if ctx.drone_last_dead_px is None or ctx.drone_last_dead_py is None or ctx.drone_hit_fade is None: continue dpx, dpy = int(ctx.drone_last_dead_px[di]), int(ctx.drone_last_dead_py[di]) fade = float(ctx.drone_hit_fade[di, fi]) if fade <= 0 or dpx < 0 or dpy < 0: continue else: # Alive — draw at current position dpx, dpy = int(ctx.px_drone[di, fi]), int(ctx.py_drone[di, fi]) fade = 1.0 if dpx < 0 or dpy < 0: continue if ctx.drone_rot_caches and ctx.drone_headings is not None and ctx.drone_headings[di, fi] >= 0: spr = ctx.drone_rot_caches[di][heading_to_rot_index(ctx.drone_headings[di, fi])] else: spr = ctx.drone_icon_sprites[di] x, y = dpx - spr.w // 2, dpy - spr.h // 2 if fade < 1.0: blit_alpha(buf, spr, x, y, fade) else: blit(buf, spr, x, y) else: # Fallback to dots if no icon sprites if ctx.drone_is_crashed is not None: draw_dead_entities_np(buf, fi, ctx.drone_colors_arr, ctx.drone_is_crashed, ctx.drone_hit_fade, # type: ignore[arg-type] ctx.drone_last_dead_px, ctx.drone_last_dead_py, # type: ignore[arg-type] ctx.drone_dy, ctx.drone_dx, cv) draw_drone_dots_np(buf, fi, ctx.px_drone, ctx.py_drone, ctx.drone_colors_arr, ctx.drone_is_crashed, ctx.drone_hit_fade, ctx.drone_dy, ctx.drone_dx, cv) # Aircraft: dead icons + alive icons if ctx.n_aircraft: assert ctx.px_air is not None and ctx.py_air is not None assert ctx.air_colors_arr is not None if ctx.air_icon_sprites: if ctx.air_is_crashed is not None: draw_dead_icon_sprites(buf, fi, ctx.air_icon_sprites, ctx.air_dead_sprites, ctx.air_is_crashed, ctx.air_hit_fade, # type: ignore[arg-type] ctx.air_last_dead_px, ctx.air_last_dead_py, # type: ignore[arg-type] rot_caches=ctx.air_rot_caches, dead_rot_caches=ctx.air_dead_rot_caches, headings=ctx.air_headings) draw_icon_sprites(buf, fi, ctx.px_air, ctx.py_air, ctx.air_icon_sprites, is_crashed=ctx.air_is_crashed, hit_fade=ctx.air_hit_fade, rot_caches=ctx.air_rot_caches, headings=ctx.air_headings) else: if ctx.air_is_crashed is not None: draw_dead_entities_np(buf, fi, ctx.air_colors_arr, ctx.air_is_crashed, ctx.air_hit_fade, # type: ignore[arg-type] ctx.air_last_dead_px, ctx.air_last_dead_py, # type: ignore[arg-type] ctx.air_dy, ctx.air_dx, cv) draw_aircraft_np(buf, fi, ctx.px_air, ctx.py_air, ctx.air_colors_arr, ctx.air_is_crashed, ctx.air_hit_fade, ctx.air_dy, ctx.air_dx, cv) items: list[tuple[Sprite, int, int]] = [] for i in range(ctx.n_players): px_i, py_i = int(ctx.px_all[i, fi]), int(ctx.py_all[i, fi]) if px_i >= 0 and py_i >= 0: ls = ctx.label_sprites[i] icon_w = ctx.player_icon_sprites[i].w if ctx.player_icon_sprites else ctx.dot_r * 2 items.append((ls, px_i + icon_w // 2 + 3, py_i - ls.h // 2)) if ctx.n_drones: assert ctx.px_drone is not None and ctx.py_drone is not None for i in range(ctx.n_drones): px_i, py_i = int(ctx.px_drone[i, fi]), int(ctx.py_drone[i, fi]) if px_i >= 0 and py_i >= 0: if ctx.drone_is_crashed is not None and ctx.drone_is_crashed[i, fi]: continue ds = ctx.drone_sprites[i] items.append((ds, px_i + ctx.drone_r + 2, py_i - ds.h // 2)) if ctx.n_aircraft: assert ctx.px_air is not None and ctx.py_air is not None assert ctx.air_alt is not None for i in range(ctx.n_aircraft): px_i, py_i = int(ctx.px_air[i, fi]), int(ctx.py_air[i, fi]) if px_i >= 0 and py_i >= 0: if ctx.air_is_crashed is not None and ctx.air_is_crashed[i, fi]: continue # Name+model label to the right ls = ctx.air_sprites[i] air_icon_w = ctx.air_icon_sprites[i].w if ctx.air_icon_sprites else ctx.air_r * 2 items.append((ls, px_i + air_icon_w // 2 + 3, py_i - ls.h // 2)) # Altitude label to the left alt_m = int(ctx.air_alt[i, fi]) if alt_m >= 0: alt_key = alt_m // 10 * 10 alt_spr = ctx.air_alt_sprites.get(alt_key) if alt_spr is not None: items.append((alt_spr, px_i - air_icon_w // 2 - 3 - alt_spr.w, py_i - alt_spr.h // 2)) if items: blit_batch(buf, items) def _get_thread_buf(tmpl: np.ndarray) -> np.ndarray: """Thread-local render buffer.""" if not hasattr(_tl, "buf"): _tl.buf = np.empty_like(tmpl) return _tl.buf # ── FFmpeg / font ────────────────────────────────────────────────────────────── def open_ffmpeg(output_path: Path, fps: int, w: int = CANVAS_MIN, h: int = CANVAS_MIN) -> subprocess.Popen: """Open an FFmpeg subprocess that reads raw RGB24 frames from stdin and encodes to H.264 MP4. Args: output_path: Destination MP4 file path. fps: Frames per second for the output video. w: Frame width in pixels. h: Frame height in pixels. Returns: A Popen handle whose stdin accepts raw RGB24 frame bytes. """ return subprocess.Popen([ "ffmpeg", "-y", "-f", "rawvideo", "-vcodec", "rawvideo", "-s", f"{w}x{h}", "-pix_fmt", "rgb24", "-r", str(fps), "-i", "pipe:0", "-vcodec", "libx264", "-preset", "ultrafast", "-crf", "34", "-pix_fmt", "yuv420p", "-threads", "4", "-movflags", "+faststart", str(output_path), ], stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: for p in ["/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/dejavu/DejaVuSans.ttf"]: if Path(p).exists(): return ImageFont.truetype(p, size) return ImageFont.load_default() # ── Helpers ──────────────────────────────────────────────────────────────────── def assign_colors(active: list[dict], team_won: int ) -> tuple[np.ndarray, np.ndarray]: """Assign per-player RGB colors based on team membership (winner vs loser palette). Args: active: Active player dicts, each with a 'Team' key. team_won: The winning team index. Returns: Tuple of (colors_arr, trail_colors_arr), both uint8 arrays of shape (n_players, 3). Trail colors are dimmed to 60% brightness. """ colors_list = [] for p in active: if p["Team"] == team_won: colors_list.append(WIN_COLOR) else: colors_list.append(LOSE_COLOR) colors_arr = np.array(colors_list, dtype=np.uint8) return colors_arr, (colors_arr * 0.6).astype(np.uint8) def make_bg(map_pil: Image.Image) -> np.ndarray: return np.asarray(map_pil).copy() # ── Public render function ───────────────────────────────────────────────────── def render_gob( d: dict, out_path: Path, fps: int = FPS, speed: float = SPEED, n_workers: int = N_WORKERS, progress_cb: Optional[Callable[[int], None]] = None, ) -> None: """ Render a GOB replay dict to an MP4 file. Args: d: Parsed GOB replay dict (from _gob_to_dict or json.load) out_path: Output MP4 path fps: Frames per second speed: Playback speed multiplier n_workers: Thread pool size for parallel frame render progress_cb: Optional callback called with int 0-100 during render """ players_by_id = {p["PlayerID"]: p for p in d["Players"]} team_won = d["TeamWon"] def build_active(entities: list[dict]) -> list[dict]: out = [] for e in entities: if e.get("PlayerID", 0) == 0: continue p = dict(players_by_id[e["PlayerID"]]) p["_samples"] = e["Path"] p["_model"] = e["ModelName"] out.append(p) return out ground_ents = [e for e in d["Entities"] if e.get("PlayerID", 0) != 0 and e["ModelName"].startswith("tankModels/")] active = build_active(ground_ents) if not active: raise ValueError("No ground unit entities found in replay.") print(f"Ground : {len(active)}") # Time grid all_t = [s["Time"] for e in ground_ents for s in e["Path"]] t_start = float(min(all_t)) t_end = float(max(all_t)) ms_per_frame = (1000.0 / fps) * speed n_frames = int(math.ceil((t_end - t_start) / ms_per_frame)) frame_times = t_start + np.arange(n_frames, dtype=np.float64) * ms_per_frame trail_f = max(1, int(TRAIL_MS * fps / 1000.0)) duration_s = (t_end - t_start) / 1000.0 print(f"Duration : {duration_s:.1f}s → ~{n_frames/fps:.0f}s video ({n_frames} frames)") # Load map coordinate bounds (local datamine, fallback to Nanachi API) level = d["Mission"]["Level"] session_id = d.get("SessionID", 0) print("Loading LevelDef …", end=" ", flush=True) level_def = load_level_coords(level, session_id) if level_def: tc0 = level_def["tankMapCoord0"] tc1 = level_def["tankMapCoord1"] print(f"ok X=[{tc0[0]}, {tc1[0]}] Z=[{tc0[1]}, {tc1[1]}]") else: tc0, tc1 = [0.0, 0.0], [4096.0, 4096.0] print("not found — falling back to [0, 4096]") # Pick canvas size dynamically: estimate player extent in world coords all_x = [s["X"] for p in active for s in p["_samples"]] all_z = [s["Z"] for p in active for s in p["_samples"]] world_span_x = max(all_x) - min(all_x) world_span_z = max(all_z) - min(all_z) map_range_x = tc1[0] - tc0[0] map_range_z = tc1[1] - tc0[1] frac = max(world_span_x / map_range_x, world_span_z / map_range_z) if map_range_x and map_range_z else 1.0 # Scale canvas so player activity ≈ MIN_OUTPUT pixels ideal_canvas = int(MIN_OUTPUT / max(frac, 0.1)) canvas = max(CANVAS_MIN, min(CANVAS_MAX, ideal_canvas & ~1)) xfm = CoordTransform(tc0[0], tc0[1], tc1[0], tc1[1], canvas=canvas) print(f"Canvas : {canvas}px (players use ~{frac*100:.0f}% of map)") print("Pre-computing positions …", end=" ", flush=True) px, py = precompute_positions(active, xfm, frame_times) print("done") kills = [k for k in d.get("Kills", []) if t_start <= k["Time"] <= t_end] damages = [dr for dr in d.get("DamageReports", []) if t_start <= dr["Time"] <= t_end] print(f"Events : {len(kills)} kills, {len(damages)} damage reports") is_dead, death_frame, death_fade, last_dead_px, last_dead_py = precompute_deaths( active, kills, t_start, ms_per_frame, n_frames, fps, px, py) wipe_f = find_team_wipe_frame(active, is_dead, n_frames) end_frame = min(wipe_f + WIPE_GRACE, n_frames) if wipe_f < n_frames: print(f"Team wipe : frame {wipe_f} (~{wipe_f/fps:.1f}s video)") colors, trail_colors = assign_colors(active, team_won) shadow_color = np.array([0, 0, 0], dtype=np.uint8) drones = build_drone_list(d, active, xfm, px, py, frame_times, t_start, ms_per_frame, colors) n_drones = len(drones) if n_drones: px_dr, py_dr = precompute_positions(drones, xfm, frame_times) drone_alt = precompute_altitudes(drones, frame_times) drone_cols = np.array([dr["color"] for dr in drones], dtype=np.uint8) drone_trail_cols = (drone_cols * 0.6).astype(np.uint8) # Match drone kills by EntityIndex (since PlayerID=0) drone_is_hit, drone_hit_fade, drone_is_crashed, drone_crash_frame, \ drone_last_dead_px, drone_last_dead_py = \ precompute_air_deaths(drones, kills, t_start, ms_per_frame, n_frames, fps, px_dr, py_dr) # Override: drones freeze at kill/path-end and fade to transparent over ~2.5s drone_fade_frames = max(1, int(2.5 * fps)) for i in range(len(drones)): hit_frames = np.where(drone_is_hit[i])[0] if len(hit_frames): # Kill-matched: freeze at kill frame hf = hit_frames[0] else: # Unmatched: freeze at path end cf = int(drone_crash_frame[i]) if cf >= n_frames: continue # path extends beyond video, drone stays alive hf = cf # Apply 2.5s fade from freeze point drone_is_hit[i, :] = False drone_is_hit[i, hf:] = True n_slots = min(drone_fade_frames + 1, n_frames - hf) drone_hit_fade[i, :] = 1.0 drone_hit_fade[i, hf:hf + n_slots] = np.maximum( 0.0, 1.0 - np.arange(n_slots, dtype=np.float32) / drone_fade_frames) drone_hit_fade[i, hf + n_slots:] = 0.0 # Freeze position at hit/end frame drone_last_dead_px[i] = px_dr[i, min(hf, px_dr.shape[1] - 1)] drone_last_dead_py[i] = py_dr[i, min(hf, py_dr.shape[1] - 1)] # Also stop the trail at this frame drone_crash_frame[i] = hf drone_is_crashed[i, :] = False if hf < n_frames - 1: drone_is_crashed[i, hf + 1:] = True print(f"Drones : {n_drones}") else: px_dr = py_dr = drone_cols = drone_trail_cols = drone_alt = None drone_is_hit = drone_hit_fade = drone_is_crashed = drone_crash_frame = None drone_last_dead_px = drone_last_dead_py = None # Aircraft — non-tank, non-drone entities with path data air_ents = [e for e in d["Entities"] if e.get("PlayerID", 0) != 0 and not e["ModelName"].startswith("tankModels/") and "ucav" not in e["ModelName"].lower() and e.get("Path")] air_active = build_active(air_ents) n_aircraft = len(air_active) if n_aircraft: px_air, py_air = precompute_positions(air_active, xfm, frame_times) air_alt = precompute_altitudes(air_active, frame_times) # Air death: keep flying after hit, fade to black, crash at end of path air_is_hit, air_hit_fade, air_is_crashed, air_crash_frame, \ air_last_dead_px, air_last_dead_py = \ precompute_air_deaths(air_active, kills, t_start, ms_per_frame, n_frames, fps, px_air, py_air) # Assign colors: match team using same win/lose scheme air_cols_list = [] for p in air_active: if p["Team"] == team_won: air_cols_list.append(WIN_COLOR) else: air_cols_list.append(LOSE_COLOR) air_cols = np.array(air_cols_list, dtype=np.uint8) air_trail_cols = (air_cols * 0.6).astype(np.uint8) print(f"Air : {n_aircraft}") else: px_air = py_air = air_cols = air_trail_cols = air_alt = None air_is_hit = air_hit_fade = air_is_crashed = air_crash_frame = None air_last_dead_px = air_last_dead_py = None dark = Image.new("RGB", (canvas, canvas), (18, 22, 28)) print("Loading tank map …", end=" ", flush=True) map_img = load_map_image(level, canvas) print("ok" if map_img else "not found — dark background") bg = make_bg(map_img or dark) # ── Auto-crop to player activity ── PAD = 80 all_px_valid = px[px >= 0] all_py_valid = py[py >= 0] if all_px_valid.size > 0: cx0 = max(0, int(all_px_valid.min()) - PAD) cy0 = max(0, int(all_py_valid.min()) - PAD) cx1 = min(canvas, int(all_px_valid.max()) + PAD) cy1 = min(canvas, int(all_py_valid.max()) + PAD) else: cx0, cy0, cx1, cy1 = 0, 0, canvas, canvas raw_w = cx1 - cx0 raw_h = cy1 - cy0 side = max(raw_w, raw_h, MIN_OUTPUT) side = (side + 1) & ~1 # round up to even (libx264 requirement) # Centre the square on the activity region, clamped to canvas mid_x = (cx0 + cx1) // 2 mid_y = (cy0 + cy1) // 2 cx0 = max(0, mid_x - side // 2) cy0 = max(0, mid_y - side // 2) if cx0 + side > canvas: cx0 = max(0, canvas - side) if cy0 + side > canvas: cy0 = max(0, canvas - side) cx1, cy1 = cx0 + side, cy0 + side crop_w = crop_h = side print(f"Crop : ({cx0},{cy0}) → ({cx1},{cy1}) {crop_w}×{crop_h}px") # Shift all coordinates into crop-space so we draw on a smaller buffer px -= cx0 py -= cy0 last_dead_px = (last_dead_px - cx0).astype(np.int16) last_dead_py = (last_dead_py - cy0).astype(np.int16) if px_dr is not None and py_dr is not None: px_dr -= cx0 py_dr -= cy0 if drone_last_dead_px is not None and drone_last_dead_py is not None: drone_last_dead_px = (drone_last_dead_px - cx0).astype(np.int16) drone_last_dead_py = (drone_last_dead_py - cy0).astype(np.int16) if px_air is not None and py_air is not None: px_air -= cx0 py_air -= cy0 if air_last_dead_px is not None and air_last_dead_py is not None: air_last_dead_px = (air_last_dead_px - cx0).astype(np.int16) air_last_dead_py = (air_last_dead_py - cy0).astype(np.int16) # Invalidate out-of-bounds positions oob = (px < 0) | (px >= side) | (py < 0) | (py >= side) px[oob] = -1; py[oob] = -1 if px_dr is not None and py_dr is not None: oob_dr = (px_dr < 0) | (px_dr >= side) | (py_dr < 0) | (py_dr >= side) px_dr[oob_dr] = -1; py_dr[oob_dr] = -1 if px_air is not None and py_air is not None: oob_air = (px_air < 0) | (px_air >= side) | (py_air < 0) | (py_air >= side) px_air[oob_air] = -1; py_air[oob_air] = -1 # Crop the background bg = bg[cy0:cy1, cx0:cx1].copy() # Scale icons and text — reference size is 1024px (original design target) scale = side / 1024.0 s_dot_r = max(2, int(DOT_R * scale + 0.5)) s_drone_r = max(1, int(DRONE_R * scale + 0.5)) s_font_sz = max(8, int(11 * scale + 0.5)) s_air_r = max(2, int(AIR_R * scale + 0.5)) s_shadow_dy, s_shadow_dx = make_circle_masks(s_dot_r + 1) s_dot_dy, s_dot_dx = make_circle_masks(s_dot_r) s_drone_dy, s_drone_dx = make_circle_masks(s_drone_r) s_air_dy, s_air_dx = make_triangle_masks(s_air_r) font = load_font(s_font_sz) kbf = precompute_kills(kills, xfm, t_start, ms_per_frame, n_frames, fps, font, offset_x=cx0, offset_y=cy0) dbf = precompute_damages(damages, active, px, py, t_start, ms_per_frame, n_frames, fps) name_sprs = make_name_sprites([p["Name"] for p in active], font) model_sprs = make_name_sprites([_short_model(p["_model"]) for p in active], font) label_sprs = [merge_sprites(ns, ms) for ns, ms in zip(name_sprs, model_sprs)] drone_spr = make_name_sprites(["(Drone)"] * n_drones, font) # Build altitude sprite cache — shared between drones and aircraft all_alt_vals: set[int] = set() if drone_alt is not None: all_alt_vals.update(int(v) // 10 * 10 for v in drone_alt[drone_alt >= 0]) if air_alt is not None: all_alt_vals.update(int(v) // 10 * 10 for v in air_alt[air_alt >= 0]) alt_sprites: dict[int, Sprite] = {} for a in all_alt_vals: sprs = make_name_sprites([f"(Alt: {a}m)"], font) alt_sprites[a] = sprs[0] # Build per-player vehicle icon sprites (tinted to player color) icon_size = max(12, int(TANK_ICON_SIZE * scale + 0.5)) hl_pad = max(1, int(ICON_HIGHLIGHT_PAD * scale + 0.5)) player_icon_sprs: list[Sprite] = [] player_icon_keys: list[str] = [] for i, p in enumerate(active): ik = get_icon_key(p["_model"]) player_icon_keys.append(ik) c = (int(colors[i][0]), int(colors[i][1]), int(colors[i][2])) player_icon_sprs.append(make_tinted_icon_sprite( ik, c, icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR)) # Dead sprites: bare black silhouette (no highlight glow) player_dead_sprs = [make_bare_black_sprite(ik, icon_size, hl_pad) for ik in player_icon_keys] print(f"Icons : {len(player_icon_sprs)} ground @ {icon_size}px (+{hl_pad}px highlight)") # Build per-aircraft icon sprites air_icon_sprs: list[Sprite] = [] if n_aircraft: assert air_cols is not None air_name_sprs = make_name_sprites([p["Name"] for p in air_active], font) air_model_sprs = make_name_sprites([_short_model(p["_model"]) for p in air_active], font) air_label_sprs = [merge_sprites(ns, ms) for ns, ms in zip(air_name_sprs, air_model_sprs)] air_icon_size = max(10, int(AIR_ICON_SIZE * scale + 0.5)) for i, p in enumerate(air_active): ik = get_icon_key(p["_model"]) c = (int(air_cols[i][0]), int(air_cols[i][1]), int(air_cols[i][2])) air_icon_sprs.append(make_tinted_icon_sprite( ik, c, air_icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR)) air_dead_sprs = [make_black_sprite(s) for s in air_icon_sprs] air_rot_caches = [make_rotation_cache(s) for s in air_icon_sprs] air_dead_rot_caches = [make_rotation_cache(s) for s in air_dead_sprs] assert px_air is not None and py_air is not None air_headings = precompute_headings(px_air, py_air) print(f"Icons : {n_aircraft} aircraft @ {air_icon_size}px (+{hl_pad}px highlight, {ROTATION_STEPS} rotations)") else: air_label_sprs = [] air_dead_sprs = [] air_rot_caches = [] air_dead_rot_caches = [] air_headings = None # Build per-drone icon sprites with rotation (like aircraft) drone_icon_sprs: list[Sprite] = [] drone_dead_sprs_list: list[Sprite] = [] drone_rot_caches: list = [] drone_dead_rot_caches: list = [] drone_headings_arr: np.ndarray | None = None drone_icon_keys: list[str] = [] if n_drones: drone_icon_size = max(10, int(AIR_ICON_SIZE * scale + 0.5)) for dr in drones: ik = get_icon_key(dr["entity"]["ModelName"]) drone_icon_keys.append(ik) c = (int(dr["color"][0]), int(dr["color"][1]), int(dr["color"][2])) drone_icon_sprs.append(make_tinted_icon_sprite( ik, c, drone_icon_size, highlight_pad=hl_pad, highlight_color=ICON_HIGHLIGHT_COLOR)) # Drones: bare black silhouette (no glow) when dead drone_dead_sprs_list = [make_bare_black_sprite(ik, drone_icon_size, hl_pad) for ik in drone_icon_keys] drone_rot_caches = [make_rotation_cache(s) for s in drone_icon_sprs] drone_dead_rot_caches = [make_rotation_cache(s) for s in drone_dead_sprs_list] if px_dr is not None and py_dr is not None: drone_headings_arr = precompute_headings(px_dr, py_dr) print(f"Icons : {n_drones} drones @ {drone_icon_size}px (+{hl_pad}px highlight, {ROTATION_STEPS} rotations)") # Load target icons for kill/damage events target_size = max(12, int(20 * scale + 0.5)) kill_target = load_target_sprite("target_red.png", target_size) dmg_target = load_target_sprite("target_yellow.png", target_size) air_trail_f = max(1, int(AIR_TRAIL_MS * fps / 1000.0)) drone_trail_f = max(1, int(DRONE_TRAIL_MS * fps / 1000.0)) ctx = VideoCtx( n_players=len(active), px_all=px, py_all=py, colors_arr=colors, trail_colors_arr=trail_colors, is_dead=is_dead, death_frame=death_frame, death_fade=death_fade, last_dead_px=last_dead_px, last_dead_py=last_dead_py, kills_by_frame=kbf, damages_by_frame=dbf, label_sprites=label_sprs, bg_arr=bg, end_frame=end_frame, n_drones=n_drones, px_drone=px_dr, py_drone=py_dr, drone_colors_arr=drone_cols, drone_trail_colors=drone_trail_cols, drone_sprites=drone_spr, drone_icon_sprites=drone_icon_sprs, drone_dead_sprites=drone_dead_sprs_list, drone_rot_caches=drone_rot_caches, drone_dead_rot_caches=drone_dead_rot_caches, drone_headings=drone_headings_arr, drone_is_hit=drone_is_hit, drone_hit_fade=drone_hit_fade, drone_is_crashed=drone_is_crashed, drone_crash_frame=drone_crash_frame, drone_last_dead_px=drone_last_dead_px, drone_last_dead_py=drone_last_dead_py, drone_alt=drone_alt, drone_alt_sprites=alt_sprites, n_aircraft=n_aircraft, px_air=px_air, py_air=py_air, air_colors_arr=air_cols, air_trail_colors=air_trail_cols, air_sprites=air_label_sprs, air_is_hit=air_is_hit, air_hit_fade=air_hit_fade, air_is_crashed=air_is_crashed, air_crash_frame=air_crash_frame, air_last_dead_px=air_last_dead_px, air_last_dead_py=air_last_dead_py, air_alt=air_alt, air_alt_sprites=alt_sprites, air_trail_f=air_trail_f, drone_trail_f=drone_trail_f, dot_dy=s_dot_dy, dot_dx=s_dot_dx, shadow_dy=s_shadow_dy, shadow_dx=s_shadow_dx, drone_dy=s_drone_dy, drone_dx=s_drone_dx, air_dy=s_air_dy, air_dx=s_air_dx, dot_r=s_dot_r, drone_r=s_drone_r, air_r=s_air_r, canvas=side, player_icon_sprites=player_icon_sprs, player_dead_sprites=player_dead_sprs, air_icon_sprites=air_icon_sprs, air_dead_sprites=air_dead_sprs, air_rot_caches=air_rot_caches, air_dead_rot_caches=air_dead_rot_caches, air_headings=air_headings, kill_target_spr=kill_target, dmg_target_spr=dmg_target, ) shared = (shadow_color, trail_f) def render_frame(fi: int) -> bytes: buf = _get_thread_buf(ctx.bg_arr) render_one_ctx(fi, buf, ctx, *shared) return buf.tobytes() ff = open_ffmpeg(out_path, fps, crop_w, crop_h) assert ff.stdin is not None fd = ff.stdin.fileno() BATCH = n_workers * 8 with ThreadPoolExecutor(max_workers=n_workers) as pool: for chunk_start in range(0, end_frame, BATCH): chunk_end = min(chunk_start + BATCH, end_frame) futs = [pool.submit(render_frame, fi) for fi in range(chunk_start, chunk_end)] for fut in futs: os.write(fd, fut.result()) if progress_cb is not None: pct = int(chunk_end / end_frame * 100) progress_cb(pct) ff.stdin.close() ff.wait() sz = out_path.stat().st_size / 1_048_576 print(f"\nDone → {out_path} ({sz:.1f} MB)") # ── GOB loading helpers ─────────────────────────────────────────────────────── def _gob_to_dict(obj: object) -> Any: """Recursively convert pygob namedtuples to plain dicts.""" if isinstance(obj, tuple) and hasattr(obj, '_fields'): # type: ignore[union-attr] return {f: _gob_to_dict(getattr(obj, f)) for f in obj._fields} # type: ignore[union-attr] elif isinstance(obj, list): return [_gob_to_dict(i) for i in obj] elif isinstance(obj, dict): return { (k.decode('utf-8', errors='replace') if isinstance(k, bytes) else k): _gob_to_dict(v) for k, v in obj.items() } elif isinstance(obj, bytes): return obj.decode('utf-8', errors='replace') return obj def load_gob_file(gob_path: Path) -> dict[str, Any]: """Load a .gob (zstd-compressed) or .json replay file and return the dict.""" raw = gob_path.read_bytes() if gob_path.suffix == ".json": return json.loads(raw) # zstd-compressed gob binary decompressor = zstd.ZstdDecompressor() data = decompressor.decompress(raw, max_output_size=200 * 1024 * 1024) replay = pygob.load(data) return _gob_to_dict(replay) # type: ignore[return-value] # ── JSON export (slim dict for the web canvas replay viewer) ────────────────── def _entity_type(model_name: str) -> str: if model_name.startswith("tankModels/"): return "ground" if "ucav" in model_name.lower(): return "drone" return "aircraft" _TAG_TO_ICON_KEY = [ ("type_spaa", "spaa"), ("type_light_tank", "light"), ("type_tank_destroyer", "tank_destroyer"), ("type_heavy_tank", "heavy"), ("type_medium_tank", "medium"), ("type_missile_tank", "tank_destroyer"), ("type_bomber", "bomber_icon"), ("type_strike_aircraft", "fighter_icon"), ("type_jet_fighter", "jet_icon"), ("type_fighter", "fighter_icon"), ("type_strike_ucav", "drone"), ("type_helicopter", "helicopter_icon"), ] def _vehicle_icon_key(model_name: str) -> str: """Return an icon key for ground vehicles: light, medium, heavy, spaa, tank_destroyer, drone. Aircraft return tag-based fallback keys (fighter_icon, bomber_icon, etc.).""" internal = model_name.split("/")[-1] if "ucav" in model_name.lower(): return "drone" tags = _get_unit_tags(internal) if tags: tag_set = set(tags) for tag, icon in _TAG_TO_ICON_KEY: if tag in tag_set: return icon return "medium" def _vehicle_mini_icon(model_name: str) -> str | None: """Return the mini icon filename for aircraft (e.g. 'spitfire_ix_ico'), or None.""" internal = model_name.split("/")[-1] mini_path = Path(__file__).resolve().parent / "ICONS" / "MINIS" / f"{internal}_ico.png" if mini_path.exists(): return f"mini:{internal}_ico" return None def _subsample_path(path: list[dict], threshold: float = 1.0) -> list[dict]: """Keep first, last, and points that moved >= threshold world units.""" if len(path) <= 2: return path out = [path[0]] lx, lz = path[0]["X"], path[0]["Z"] for pt in path[1:-1]: dx = pt["X"] - lx dz = pt["Z"] - lz if dx * dx + dz * dz >= threshold * threshold: out.append(pt) lx, lz = pt["X"], pt["Z"] out.append(path[-1]) return out def _resolve_drone_team(drone_entity: dict, ground_entities: list[dict], players_by_id: dict) -> int: """Return team index for a drone by finding nearest ground player at spawn.""" if not drone_entity.get("Path") or not ground_entities: return 0 spawn_t = drone_entity["Path"][0]["Time"] spawn_x = drone_entity["Path"][0]["X"] spawn_z = drone_entity["Path"][0]["Z"] best_team = 0 best_dist = float("inf") for ge in ground_entities: pid = ge.get("PlayerID", 0) if pid == 0 or pid not in players_by_id: continue closest_pt = None closest_dt = float("inf") for pt in ge.get("Path", []): dt = abs(pt["Time"] - spawn_t) if dt < closest_dt: closest_dt = dt closest_pt = pt if closest_pt is None: continue dx = closest_pt["X"] - spawn_x dz = closest_pt["Z"] - spawn_z dist = dx * dx + dz * dz if dist < best_dist: best_dist = dist best_team = players_by_id[pid].get("Team", 0) return best_team def export_replay_json(gob_path: Path) -> dict: """Load a GOB file and produce a slim dict for the web viewer.""" d = load_gob_file(gob_path) players_by_id = {p["PlayerID"]: p for p in d.get("Players", [])} team_won = d.get("TeamWon", 0) level_path = d.get("Mission", {}).get("Level", "") session_id = d.get("SessionID", 0) level_data = load_level_coords(level_path, session_id=session_id) if level_data: c0 = level_data["tankMapCoord0"] c1 = level_data["tankMapCoord1"] level_coords = {"x0": c0[0], "z0": c0[1], "x1": c1[0], "z1": c1[1]} else: level_coords = {"x0": 0, "z0": 0, "x1": 4096, "z1": 4096} map_coords = None full_map_level = None if level_data and "mapCoord0" in level_data and "mapCoord1" in level_data: mc0 = level_data["mapCoord0"] mc1 = level_data["mapCoord1"] if isinstance(mc0[0], list): mc0 = mc0[0] if isinstance(mc1[0], list): mc1 = mc1[0] map_coords = {"x0": mc0[0], "z0": mc0[1], "x1": mc1[0], "z1": mc1[1]} stem = Path(level_path).stem if level_path else "" custom = level_data.get("customLevelMap", "") if custom: full_map_level = custom.rstrip("*") else: full_map_level = stem + "_map" if stem else None players_out = [] for p in d.get("Players", []): player = { "id": p["PlayerID"], "name": p.get("Name", ""), "team": p.get("Team", 0), } clan = p.get("ClanTag", "") if clan: player["clan"] = clan players_out.append(player) ground_entities = [e for e in d.get("Entities", []) if e.get("PlayerID", 0) != 0 and e["ModelName"].startswith("tankModels/") and e.get("Path")] entities_out = [] for e in d.get("Entities", []): path = e.get("Path", []) if not path: continue pid = e.get("PlayerID", 0) etype = _entity_type(e["ModelName"]) if pid == 0 and etype != "drone": continue internal = e["ModelName"].split("/")[-1] vehicle_name = _translate_vehicle(internal) if etype == "drone": drone_team = _resolve_drone_team(e, ground_entities, players_by_id) else: drone_team = None sampled = _subsample_path(path) path_out = [{"t": round(pt["Time"]), "x": round(pt["X"], 1), "z": round(pt["Z"], 1), "y": round(pt.get("Y", 0), 1)} for pt in sampled] icon_key = _vehicle_icon_key(e["ModelName"]) ent = { "playerId": pid, "entityIndex": e.get("EntityIndex", 0), "type": etype, "iconKey": icon_key, "vehicleName": vehicle_name, "path": path_out, } if etype == "aircraft": mini = _vehicle_mini_icon(e["ModelName"]) if mini: ent["miniIcon"] = mini if drone_team is not None: ent["droneTeam"] = drone_team entities_out.append(ent) kills_out = [] for k in d.get("Kills", []): kill = { "time": round(k.get("Time", 0)), "victimId": k.get("VictimID", 0), "killerId": k.get("KillerID", 0), "victimEntityIndex": k.get("VictimEntityIndex", 0), "weapon": _translate_weapon(k.get("Weapon", "")), } vp = k.get("VictimPosition") if vp: kill["victimPos"] = {"x": round(vp.get("X", 0), 1), "z": round(vp.get("Z", 0), 1)} kp = k.get("KillerPosition") if kp: kill["killerPos"] = {"x": round(kp.get("X", 0), 1), "z": round(kp.get("Z", 0), 1)} km = k.get("KillerModel", "") if km: kill["killerVehicle"] = _translate_vehicle(km.split("/")[-1]) vm = k.get("VictimModel", "") if vm: kill["victimVehicle"] = _translate_vehicle(vm.split("/")[-1]) kills_out.append(kill) damages_out = [] for dm in d.get("DamageReports", []): damages_out.append({ "time": round(dm.get("Time", 0)), "offenderId": dm.get("OffenderID", 0), "offendedId": dm.get("OffendedID", 0), }) out = { "teamWon": team_won, "mission": {"level": Path(level_path).stem if level_path else ""}, "levelCoords": level_coords, "players": players_out, "entities": entities_out, "kills": kills_out, "damages": damages_out, } if map_coords and full_map_level: out["mapCoords"] = map_coords out["fullMapLevel"] = full_map_level return out # ── Main (CLI wrapper) ───────────────────────────────────────────────────────── def main(): """CLI entry point: render a GOB replay to MP4, or export a slim JSON for the web viewer. Output mode is selected by the output file extension: `.json` → json export, anything else → mp4 render. Supports --profile for cProfile hotspot analysis. """ import argparse parser = argparse.ArgumentParser(description="Render GOB replay to MP4") parser.add_argument("gob", nargs="?", help="Path to .gob or .json replay") parser.add_argument("out", nargs="?", help="Output .mp4 path") parser.add_argument("--fps", type=int, default=FPS) parser.add_argument("--speed", type=float, default=SPEED) parser.add_argument("--workers", type=int, default=N_WORKERS) parser.add_argument("--profile", action="store_true", help="Run with cProfile and print top 40 hotspots") args = parser.parse_args() if args.gob: gob_path = Path(args.gob) else: candidates = sorted(REPLAYS_DIR.glob("*/replay.gob")) if not candidates: candidates = sorted(REPLAYS_DIR.glob("*.json")) if not candidates: sys.exit(f"No .gob or .json files in {REPLAYS_DIR}") gob_path = candidates[0] out_path = Path(args.out) if args.out else gob_path.parent / "replay_video.mp4" if out_path.suffix.lower() == ".json": data = export_replay_json(gob_path) raw = json.dumps(data, separators=(",", ":")) out_path.write_text(raw, encoding="utf-8") print(f"Exported {len(raw):,} bytes to {out_path}") return print(f"Input : {gob_path}") print(f"Output : {out_path}") print(f"Settings : {args.fps}fps {args.speed:.0f}× {args.workers} threads") d = load_gob_file(gob_path) if args.profile: import cProfile import pstats import io import time # Phase 1: profile just the prep (everything before frame loop) # We do this by profiling render_gob with 0 workers trick — not feasible, # so profile the whole thing and the stats will show us. print("\n=== PROFILING ===\n") t0 = time.perf_counter() pr = cProfile.Profile() pr.enable() render_gob(d, out_path, fps=args.fps, speed=args.speed, n_workers=args.workers) pr.disable() wall = time.perf_counter() - t0 print(f"\n{'='*70}") print(f"Total wall time: {wall:.2f}s") print(f"{'='*70}\n") s = io.StringIO() ps = pstats.Stats(pr, stream=s) ps.strip_dirs().sort_stats("cumulative") ps.print_stats(40) print(s.getvalue()) s2 = io.StringIO() ps2 = pstats.Stats(pr, stream=s2) ps2.strip_dirs().sort_stats("tottime") ps2.print_stats(40) print("\n--- Sorted by total time ---\n") print(s2.getvalue()) else: render_gob(d, out_path, fps=args.fps, speed=args.speed, n_workers=args.workers) if __name__ == "__main__": main()