# _runroll.py — roll ONE character's 8-frame RUN CYCLE: one call, one row, one character. # # uv run --with pillow --with numpy --with requests python assets/_runroll.py p # compose ref + roll # uv run --with pillow --with numpy --with requests python assets/_runroll.py p --cut # cut + gate -> review dir # uv run --with pillow --with numpy --with requests python assets/_runroll.py p --install # cut + gate -> assets/ # keys: p (player m), pf (player f), o (opponent m), of (opponent f) # # WHY. The p_run*/o_run* frames were generated ONE AT A TIME in the pre-pipeline era, and it shows, # measurably: the male player's four frames are 91/110/92/98 px wide, frames 1/3 and 2/4 are near # duplicates of each other drawn differently, and the racket is missing outright. That is the drift # the family pipeline exists to make impossible, so the run cycles go through the same discipline: # # IDENTITY COMES FROM A COMPOSED REFERENCE — settled frames already on disk, pasted locally at one # scale, NEAREST, no API round trip (assets/_pose_ref_run_.png, tracked). The broken run frames # stay OUT of it: the model copies what it is shown, and they are the defect being replaced. # THE POSES COME FROM THE PROMPT'S SKELETON LANGUAGE, not from example art — a run cycle is the # one pose set the generator's own cliché is RIGHT about; we only pin the frame count, the order, # the facing and the racket. # # FACING: every char sprite is drawn facing RIGHT and render.js mirrors on facing < 0 (see the note # at gameplay.js's swing aim). The old o_run art faced LEFT — the male opponent has been moonwalking # to the right since it was installed — so the facing is pinned in the prompt and gated after the cut. import base64, os, sys import requests from PIL import Image HERE = os.path.dirname(os.path.abspath(__file__)) SCRATCH = os.path.join(HERE, '_refcheck') MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image') N = 6 # frames per cycle — render.js probes run4/run6/run8 and subdivides the same stride. # SIX, not eight: at eight the model pads the row with near-duplicates and the gait # blurs into a hop. Six is three phases and their mirror, which is what a run IS. TARGET_H = 224 # SPRITE_H: the cut anchors its tallest frame here, like every char sheet # key -> identity frames (settled art, the character), install (prefix, suffix), who (words that # stop the model averaging the reference into a generic figure), view (the camera + direction). CHARS = { 'p': {'id': ['p_idle1', 'p_bounce', 'p_swing1', 'p_smash', 'p_toss'], 'install': ('p_run', ''), 'who': 'the LIVING male tennis player of image 1: neat brown hair under a white headband, ' 'a white polo shirt, blue shorts, white socks and white sneakers, tanned skin, ' 'carrying a wooden tennis racket', 'view': 'SEEN FROM BEHIND AND ABOVE, running AWAY from the camera and off to the right — ' 'the camera stands behind the baseline looking down-court at his back, exactly the ' 'angle image 1 is drawn from. You see his shoulder blades, the back of his head ' 'and the soles of his shoes as they lift; only a sliver of cheek shows past the ' 'right shoulder. The body is turned about 30 degrees off straight-away so he ' 'travels diagonally away and to the RIGHT. A SIDE-ON PROFILE IS WRONG: if a figure ' 'shows its whole face, chest or a full profile silhouette, that figure is WRONG'}, 'pf': {'id': ['p_idle1_f', 'p_bounce_f', 'p_swing1_f', 'p_smash_f', 'p_toss_f'], 'install': ('p_run', '_f'), 'who': 'the LIVING female tennis player of image 1: a dark ponytail, a white polo shirt, ' 'a white pleated tennis skirt, white socks and sneakers, carrying a wooden tennis racket', 'view': 'SEEN FROM BEHIND AND ABOVE, running AWAY from the camera and off to the right — ' 'the camera stands behind the baseline looking down-court at her back, exactly the ' 'angle image 1 is drawn from. You see her shoulder blades, the ponytail swinging ' 'off the back of her head and the soles of her shoes as they lift; only a sliver ' 'of cheek shows past the right shoulder. The body is turned about 30 degrees off ' 'straight-away so she travels diagonally away and to the RIGHT. A SIDE-ON PROFILE ' 'IS WRONG: if a figure shows its whole face, chest or a full profile silhouette, ' 'that figure is WRONG'}, 'o': {'id': ['o_idle1', 'o_bounce', 'o_swing1', 'o_smash', 'o_toss'], 'install': ('o_run', ''), 'who': 'the male tennis opponent of image 1: shaggy blond hair under a red headband, a red ' 'polo shirt with a dark collar, dark grey shorts, red socks, white sneakers, ' 'carrying a wooden tennis racket', 'view': 'in three-quarter FRONT view facing the camera like image 1, sprinting toward the ' 'RIGHT edge of the frame'}, 'of': {'id': ['o_idle1_f', 'o_bounce_f', 'o_swing1_f', 'o_smash_f', 'o_toss_f'], 'install': ('o_run', '_f'), 'who': 'the female tennis opponent of image 1: a light-brown ponytail, a red polo shirt, a ' 'dark grey pleated skirt, dark red shoes, carrying a wooden tennis racket', 'view': 'in three-quarter FRONT view facing the camera like image 1, sprinting toward the ' 'RIGHT edge of the frame'}, } MAGENTA = (255, 0, 255) REF_SIZE = (6720, 2880) # 21:9 — the aspect a one-row-of-eight roll is asked at, so the reference MARGIN = 0.03 # is composed at the same enum value and nothing gets rearranged to fit. GAP = 0.22 # gap between identity figures, as a fraction of their height def ref_path(k): return os.path.join(HERE, f'_pose_ref_run_{k}.png') def compose(k): """The identity reference: the character's SETTLED frames, one scale, NEAREST, no API.""" frames = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in CHARS[k]['id']] rh = max(f.height for f in frames) gap = round(rh * GAP) cw = sum(f.width for f in frames) + gap * (len(frames) - 1) s = min(REF_SIZE[0] * (1 - 2 * MARGIN) / cw, REF_SIZE[1] * (1 - 2 * MARGIN) / rh) sheet = Image.new('RGB', REF_SIZE, MAGENTA) x = round((REF_SIZE[0] - cw * s) / 2) ground = round((REF_SIZE[1] + rh * s) / 2) # one ground line, bottom-aligned for f in frames: r = f.resize((round(f.width * s), round(f.height * s)), Image.NEAREST) sheet.paste(r, (x, ground - r.height), r) x += r.width + round(gap * s) sheet.save(ref_path(k)) print(f'{ref_path(k)} {sheet.size} {len(frames)} figures at one scale {s:.2f}') def prompt(k): # THE BACKGROUND LINE COMES FIRST AND COMES FROM _regen.py (PROJECT.md's rule, re-learned # 2026-08-15): the back-view re-roll's freehand magenta prose lost to the camera change and the # sheet came back WHITE with a ghosted second row above the figures. sys.path.insert(0, HERE) from _regen import BG_FIRST c = CHARS[k] return ( BG_FIRST + '\n\n' f'Draw a pixel-art sprite sheet: ONE ROW of EXACTLY {N} figures on that flat magenta ' f'background. COUNT THEM: {N} figures, no fewer, no more — a row of 7 or 9 is WRONG. ONE ' 'SINGLE HORIZONTAL ROW on one ground line: never a grid, never two rows, never a strip of ' 'extra heads or half-bodies above or below the row, and no faded, blurred, ghosted or ' 'onion-skinned figures anywhere on the canvas — every figure is fully drawn and fully opaque. ' 'THE FIGURES ARE SMALL IN THE FRAME: about two thirds of the canvas height, with a band of ' 'empty magenta roughly a sixth of the canvas wide above, below and beyond the outermost ' 'figure on each side.\n\n' f'IMAGE 1 is the ONLY source for who the character is: {c["who"]}. Copy the build, the ' 'garments, the hair and every one of its colours exactly. The poses in image 1 exist only to ' 'show the character — do NOT copy the poses.\n\n' f'EVERY ONE OF THE {N} FIGURES GRIPS THE WOODEN TENNIS RACKET IN THE RIGHT HAND, carried low ' 'at the side mid-run. A figure with an empty hand is WRONG. THE RACKET STAYS ON THE RIGHT ' 'SIDE OF THE BODY IN EVERY SINGLE FIGURE — it never swings across the body, never tucks ' 'behind the back to the left, never changes sides between neighbouring figures. The right ' 'arm swings a LITTLE; the racket head stays visibly right of the body\'s midline throughout. ' 'A single figure with the racket on the left side makes the whole animation flicker and is ' f'WRONG. BEFORE YOU FINISH, COUNT THE RACKETS: exactly {N}, one per figure — a row where one ' 'figure lost its racket is WRONG, and that is the exact defect this row replaces. Seen from ' 'behind the racket is FORESHORTENED and half-hidden by the hip — a short wooden shaft and an ' 'oval head angled away from the camera, never a broad flat paddle held out to the side. ' 'THE INSIDE OF EVERY RACKET HEAD IS EXACTLY THE BACKGROUND MAGENTA #FF00FF — never ' 'white, never grey, never solid: it cuts to transparency, the same rule the family sheets ' 'follow (a white-filled bed shipped as a solid paddle once — that is what this line prevents).\n\n' f'THE {N} FIGURES ARE ONE CONTINUOUS RUN CYCLE, {c["view"]}. Evenly spaced, every figure on ' 'one shared ground line, every figure at the same scale as its neighbours. THE FIGURES ' 'NEVER TOUCH OR OVERLAP: leave a clear band of empty magenta between every pair of ' 'neighbours — no part of any figure, the racket included, reaches into its neighbour\'s ' 'space. A racket overlapping the next figure is WRONG.\n\n' # THE GAIT IS THE WHOLE JOB, and from behind it is carried by ONE readable fact per figure: # which shoe is flat on the ground and which sole is showing to the camera. Told as "the legs # alternate" the model drew a row of small symmetrical hops — Oliver: "the feet look rather # like ropeskipping than running". Named LEFT and RIGHT, figure by figure, it has something # to check itself against. 'THE CYCLE, FIGURE BY FIGURE — name the left and right leg in each one and draw exactly this:\n' '1. The LEFT shoe is flat on the ground under the hips carrying the whole body; the RIGHT ' 'knee is driving up and FORWARD in front of the body, that thigh climbing so the right shoe ' 'is well clear of the ground. The body sits at its LOWEST here.\n' '2. Push-off: the LEFT leg snaps straight and trails BEHIND the body with the whole SOLE of ' 'the left shoe tipped up toward the camera, while the RIGHT foot reaches forward and lands ' 'ahead. The two shoes are far apart.\n' '3. Flight: BOTH feet are off the ground, the RIGHT foot forward and low about to take the ' 'weight, the LEFT foot high behind with its sole full to the camera. This is the WIDEST ' 'figure of the row and the body rides HIGHEST.\n' '4. The mirror of 1: the RIGHT shoe flat on the ground under the hips, the LEFT knee driving ' 'up and forward.\n' '5. The mirror of 2: the RIGHT leg straight and trailing behind, its sole to the camera, the ' 'LEFT foot reaching forward.\n' '6. The mirror of 3: flight again, LEFT foot forward and low, RIGHT foot high behind.\n' 'IN EVERY SINGLE FIGURE ONE LEG IS CLEARLY AHEAD OF THE OTHER and the two shoes are at ' 'DIFFERENT heights. Two feet side by side under the hips, or two legs bent the same way, is ' 'SKIPPING WITH A ROPE, not running, and is WRONG. The leading leg must CHANGE SIDES from each ' 'figure to the next — never the same leg leading twice in a row, the last figure included, ' 'because the row is played as a loop. The hips and shoulders counter-rotate a little with ' 'each stride, and the free LEFT arm swings forward when the RIGHT knee drives.\n\n' 'MATCH THE DRAWING STYLE OF IMAGE 1 EXACTLY. Image 1 is hard-edged pixel art and your output ' 'must be indistinguishable from it in technique: the same square pixel grid at the same pixel ' 'size, the same hard dark outline around every figure, the same flat blocks of colour, the ' 'same stepped aliased edges, the same hand-placed dithering. No soft brushwork, no blur, no ' 'gradients, no anti-aliasing.\n\n' 'THIS IS A HARD SPRINT, NOT A JOG. At the widest figure the shoes are at least two shoe ' 'lengths apart and the torso leans forward over the leading knee; the trailing heel kicks up ' 'toward the backside. A row of figures standing upright with their feet close together is a ' 'WALK and is WRONG.\n\n' 'EVERY FIGURE IS THE SAME SIZE as its neighbours and every figure stands on ONE shared ' 'horizontal ground line — the airborne figure lifts a little off it, nobody floats above the ' 'row.\n\n' f'LAST CHECK BEFORE YOU DRAW: walk the row figure by figure, 1 to {N}, and for each one name ' 'the racket in its right hand and the knee that leads. Every figure has a racket. No two ' 'neighbours lead with the same knee.\n\n' 'The background is a solid, flat, uniform chroma key. Use EXACTLY hex color #FF00FF, one ' 'single flat tone across the whole canvas behind every figure, with no panels or boxes of any ' 'other colour. The inside of the racket head is that same EXACT #FF00FF. No text, no shadows, ' 'no ground lines.') def api_key(): if os.environ.get('OPENROUTER_API_KEY'): return os.environ['OPENROUTER_API_KEY'] for line in open(os.path.join(HERE, '..', '.env')): if line.startswith('OPENROUTER_API_KEY='): return line.split('=', 1)[1].strip().strip('"').strip("'") sys.exit('no OPENROUTER_API_KEY') def u(p): return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode() def roll(k): r = requests.post('https://openrouter.ai/api/v1/images', headers={'Authorization': f'Bearer {api_key()}', 'Content-Type': 'application/json'}, # 4K and 21:9 spelled out: the endpoint defaults to 1K and snaps aspect to an enum — the # reference is composed at 21:9 for exactly this reason (see PROJECT.md, the API facts). json={'model': MODEL, 'prompt': prompt(k), 'resolution': '4K', 'aspect_ratio': '21:9', 'input_references': [{'type': 'image_url', 'image_url': {'url': u(ref_path(k))}}]}, timeout=1800) if r.status_code != 200: sys.exit(f'{k}: HTTP {r.status_code} {r.text[:300]}') d = r.json().get('data') or [] if not d: sys.exit(f'{k}: no image returned') b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1] os.makedirs(SCRATCH, exist_ok=True) out = os.path.join(SCRATCH, f'run_{k}.png') open(out, 'wb').write(base64.b64decode(b)) print(f'{k}: {out} {Image.open(out).size}', flush=True) def cut(k, install=False, n=0): """Cut the roll with _fine's machinery, gate the stride, and (on --install) place the frames. THE COUNT IS WHAT THE MODEL DREW, 6..8 — asked for 8, it returns 6 or 7 as often as not, and a clean 6-frame cycle beats a fought-for 8 (the row it drew at its own count is the coherent one). render.js probes run8/run6/run4, so the game wires whichever count survives review. `--n ` states the count when the figures TOUCH (a low-carried racket bridges every gap, so blob-counting reads one figure): the count is then a thing you judged by eye off the roll, which is the pipeline's own rule, and _fine's valley rescue does the cutting.""" sys.path.insert(0, HERE) import _fine from _famref import legs, stride_diff from PIL import Image as I sheet = os.path.join(SCRATCH, f'run_{k}.png') if not os.path.exists(sheet): sys.exit(f'{sheet}: no roll — run `_runroll.py {k}` first') got = n or len(_fine.frames(_fine.key(I.open(sheet)), [])) # count what actually arrived if not 6 <= got <= 8: sys.exit(f'{k}: {got} figures on the sheet — outside 6..8. Re-roll, or state the count ' 'you can see with --n ') pre, suf = CHARS[k]['install'] outdir = HERE if install else os.path.join(SCRATCH, f'runcut_{k}') os.makedirs(outdir, exist_ok=True) outs = [os.path.join(outdir, f'{pre}{i}{suf}.png') for i in range(1, got + 1)] _fine.main(sheet, outs, TARGET_H, [], -1, bed=True) # ANCHOR EVERY FRAME ON ITS FEET. drawChar centres a frame by its total width, and the racket's # reach varies wildly across a cycle (the cut female frames measured -28..+15 px of feet drift # from frame centre) — so the BODY teleported sideways every frame, which is exactly Oliver's # "very flashy and unnatural". Pad each frame with transparency until the lower-third centroid # (the same surface the stride gate reads) sits at the frame's horizontal centre. import numpy as np for o in outs: im = Image.open(o).convert('RGBA') a = np.array(im) op = a[..., 3] > 40 ys, xs = np.nonzero(op[int(op.shape[0] * 0.66):]) if not len(xs): continue cx = xs.mean() off = round(2 * (cx - im.width / 2)) # feet right of centre -> pad RIGHT, and vice versa: if off == 0: continue # new centre (w+|off|)/2 then lands on the feet pad = Image.new('RGBA', (im.width + abs(off), im.height), (0, 0, 0, 0)) pad.paste(im, (0 if off > 0 else abs(off), 0)) pad.save(o) # THE RACKET-SIDE GATE. One frame with the racket swung across the body makes the whole cycle # flicker left-right at stride rate — Oliver: "the racket she is holding flashing left and # right very fast" (the female cycle's frame 4 was the offender). racket_side locates the bed # (pale blob or keyed hole); every frame must keep it right of centre, or at worst dead-centre. # ...as a WARNING, not a gate: on back-view art the bed is part-occluded and the centroid # reads ±0.05 of noise — it never flagged the original offender (old frame 4 measured -0.02). # The montage review is the real gate; a strongly negative number here is what to look at. from _famref import racket_side sides = [racket_side(Image.open(o)) for o in outs] print(' racket sides:', ['%s%+.2f' % ('?' if s is None else '', s or 0) for s in sides]) crossed = [i + 1 for i, s in enumerate(sides) if s is not None and s < -0.12] if crossed: print(f' WARNING: racket may cross LEFT in frame(s) {crossed} — check the montage hard') # THE STRIDE GATE, on what was actually cut: every neighbouring pair (wrapping — it loops) must # differ in the legs, or the cycle holds a duplicated pose. Threshold calibrated against # _famref's (30% = a real stride pair; 12 catches only true near-duplicates). L = [legs(Image.open(o)) for o in outs] bad = [] for i in range(got): d = stride_diff(L[i], L[(i + 1) % got]) print(f' stride {i + 1}->{(i + 1) % got + 1}: {d:5.1f}%') if d < 12: bad.append(f'{i + 1}->{(i + 1) % got + 1} at {d:.1f}%') if bad: sys.exit('GATE FAILED: near-identical neighbour frames: ' + ', '.join(bad)) print(f'{k}: {got} frames -> {outdir}' + ('' if install else ' (review, then --install)')) if __name__ == '__main__': argv = sys.argv[1:] n = 0 if '--n' in argv: i = argv.index('--n'); n = int(argv[i + 1]); del argv[i:i + 2] args = [a for a in argv if not a.startswith('-')] if len(args) != 1 or args[0] not in CHARS: sys.exit(f'usage: _runroll.py [--cut|--install] [--n ] keys: {" ".join(CHARS)}') k = args[0] if '--install' in argv: cut(k, install=True, n=n) elif '--cut' in argv: cut(k, n=n) else: compose(k); roll(k)