Six sessions of asking an image model for a row of running figures, and it
never once drew a gait: three phases repeated with the same leg leading,
"ropeskipping, not running". A frame-by-frame LEFT/RIGHT skeleton spec, a
closing checklist, a loop clause, six figures instead of eight — all lost.
One 5-second image-to-video clip has no such problem, because it moves a
body instead of drawing poses. ~$0.42 a character through the key already in
.env, pose pinned at BOTH ends (pinned only at the front, she rotated into
profile halfway), stride period MEASURED off the frames rather than sampled
by eye — hers repeats in 6 clip frames, his in 23 — and the phases cut and
palette-snapped through _stride.cut. assets/_vidroll.py films and cuts it;
her first frame came from one still roll that took his approved pose as a
picture instead of a paragraph.
Wired: runN probes the sheet through SK() so the women's tour counts HER
frames, the player is no longer clamped to a two-frame pair, runHz 2.4 ->
1.9 ("this looks like roadrunner"), and the bench grew two PLAYER stops —
a cycle only ever seen mid-rally is a cycle nobody has watched.
Also in: the difficulty retune (medium is a real step above the shipped CPU
and is where `smart` first appears; hard stepped back off superhuman; easy
closed its gap) with MEDIUM's dial-for-dial pin to TUNE removed from the
test, and Oliver's own transparency fixes on the male frames.
SPRV 138 — the frames were right on disk and wrong on screen for an hour
because assets are cached for a year unless the ?v= moves. main.js v269.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
20 KiB
Python
299 lines
20 KiB
Python
# _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_<k>.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 <count>` 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 <count>')
|
|
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 <key> [--cut|--install] [--n <count>] 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)
|