Files
splatter-tennis/assets/_runroll.py
T
wuwiandClaude Fable 5 c847bcbfa1 The racket holds still: both player run cycles re-rolled steady-handed
Oliver, after the facing fix: still flashing, female worst. Film-strip
diagnosis in the pane: with facing pinned the racket STILL alternated —
frame 4 of the female cycle tucked it across the body, and half the frames
of both player cycles lost or shrank the hoop to cut fragments (overlapping
figures sliced by the even-spaced valley cuts).

Both cycles re-rolled with three new prompt rules that now live in
_runroll.py: the racket stays right of the midline in EVERY figure (named
again for the airborne pose, where it kept vanishing), and the figures
never overlap (no more sliced hoops — a debris-stripping pass was tried
and removed: after the recrop a real racket is indistinguishable from a
fragment, prevention beats cleanup). The model returns 2-row grids at
times; a coherent band is cropped and cut. Installed: racket-side spread
+0.01..+0.05 across all frames of both cycles (was a full side swap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:30:40 +02:00

244 lines
15 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 = 8 # frames per cycle — render.js probes run4/run8 and subdivides the same stride
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': 'his BACK to the camera in three-quarter view, the body angled toward the right, '
'running to the RIGHT along the baseline — the right edge of his face just visible '
'past his shoulder. NOT a straight-away back view: he moves ACROSS the frame, '
'rightward, every figure leaning into that rightward run'},
'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': 'her BACK to the camera in three-quarter view, the body angled toward the right, '
'running to the RIGHT along the baseline — the right edge of her face just visible '
'past her shoulder. NOT a straight-away back view: she moves ACROSS the frame, '
'rightward, every figure leaning into that rightward run'},
'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):
c = CHARS[k]
return (
f'Draw a pixel-art sprite sheet: ONE ROW of EXACTLY {N} figures on a flat magenta background. '
f'COUNT THEM: {N} figures, no fewer, no more — a row of 7 or 9 is WRONG.\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 '
'WRONG.\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 cycle, frame by frame: 1 the right foot planted under the body, left knee driving up and '
'forward; 2 push-off, the right leg trailing straight behind, the left leg reaching forward; '
'3 full flight, both feet off the ground, the stride at its widest — THE RACKET STILL '
'GRIPPED IN THE RIGHT HAND in this airborne figure, exactly as in every other; 4 the left foot landing '
'ahead of the body. Frames 5 to 8 are the SAME four moments with the legs swapped — the left '
'foot planted, the right knee driving. Across the row the gap between the feet opens, closes, '
'opens and closes again. The arms counter-swing: the opposite arm to the driving knee comes '
'forward. If two neighbouring figures have the same leg forward, the row is WRONG.\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'
'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)
# 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)