# _crowdroll.py — roll the crowd sheet itself: the SEATED master, and the arms-up frame of it. # # uv run --with pillow --with requests python assets/_crowdroll.py seat # -> _refcheck/crowd_seat.png # uv run --with pillow --with requests python assets/_crowdroll.py clap # from that seated roll # # _refcheck is SCRATCH. A roll you decide to keep is copied to `assets/_sheet_crowd3.png` (seated) # and `_sheet_crowd3b.png` (arms up) — those two are the tracked art, they are what `_crowdpass.py` # reads, and they are the only copy there is. Then `_crowdpass.py` turns them into an honest pair # and grades them into the three courts. # # WHY A SECOND SCRIPT. `_regen.py` cuts SPRITES out of a magenta field — every line of its canvas # block is about isolating figures on a key colour. The crowd is the opposite job: one continuous # backdrop image that is drawn edge to edge with nothing keyed out, so it needs its own prompt and # its own aspect. The one thing it borrows is the lesson those rolls paid for: SAY THE ART # RESOLUTION IN FIGURE HEIGHTS, never in pixels — the generator picks its own canvas, so "90 pixels # tall" is a proportion it can act on and "1408 wide" is not. # # The seated roll takes the OLD sheet as image 1 for LAYOUT ONLY (tiers, railing, camera angle, # people-per-row) and is told in as many words that the drawing itself is to be redone finer. The # clap roll takes the NEW seated master as image 1 and asks for the same picture with some of those # people on their feet — it WILL drift, which is fine and expected: `_crowdpass.py` keeps only the # blobs where arms genuinely went up and throws the drift away. import base64, os, sys import requests from PIL import Image HERE = os.path.dirname(os.path.abspath(__file__)) OUT = os.path.join(HERE, '_refcheck') MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image') SIZE = (1408, 768) # what the game ships; the roll comes back bigger and is boxed down to it def 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') # The stand is drawn STRETCHED into whatever band the viewport leaves, and at that size a spectator # is ~8-14 logical px. So the prompt spends everything on the two things that survive: how many # DIFFERENT people there are, and what breaks the top line of a row. STYLE = ( "16-bit SNES-era pixel art, crisp hard-edged pixels, a limited palette, dark outlines separating " "figures, flat blocks of colour with a little dithered shading. No photographic blur, no soft " "anti-aliased haze, no gradients, no lens effects, no letters, words, numbers or logos anywhere " "in the image. " ) SEAT = ( "Pixel art of a PACKED TENNIS STADIUM GRANDSTAND seen straight on from the court, filling the " "entire frame edge to edge: a wall of spectators in tiered rows, one above the other. " "USE THE REFERENCE IMAGE FOR LAYOUT ONLY — the same head-on camera, the same tiers of seating, " "the same horizontal railing running across the picture, the same darker band of wall above the " "top row, the same rows running right off both edges with no border or margin anywhere. " "REDRAW EVERYTHING ELSE AT MUCH FINER PIXEL RESOLUTION WITH FAR MORE DETAIL than the reference: " "build each spectator out of roughly 90 pixels of height instead of the reference's 40, and " "spend all of it on making them DIFFERENT PEOPLE. " "Every spectator is an individual: a wide range of skin tones; hair that is short, long, bald, " "tied back, curly, grey, blond, ginger, black; beards; caps and sun hats in bright colours; " "sunglasses; visors; headbands. Clothing in many different saturated colours — tennis whites, " "striped polos, jerseys, summer dresses, jackets tied over shoulders, vest tops. " "They are DOING things: some clap, some hold drinks or ice creams, some hold a camera with a " "long lens up to one eye, some wave a small flag on a stick, some hold up a plain hand-painted " "banner of flat colour with NO writing on it, some lean together talking, some shade their eyes, " "a few stand with both arms raised. " "Break the rows up: scattered EMPTY SEATS with the seat back showing, and a couple of vertical " "stairway gaps between blocks of seating. Not every head at the same height. " "THREE OF THE SPECTATORS ARE ZOMBIES, sitting in the middle of the crowd among everybody else: " "grey-green skin, sunken dark eye sockets, slack jaw, slumped and staring — and the ordinary " "people sitting next to them are behaving completely normally, paying them no attention. " "Bright neutral daylight, evenly lit across the whole picture. " + STYLE ) # FRAME 3 — the other half of the clap. Rolled FROM the arms-up frame, not from the seated one, so # it is the SAME forty people standing: what changes is where their hands are. Two raised poses per # spectator is what turns "his arms appeared" into "he is clapping" (Oliver: "can't we make an # animation sprite sheet of the new crowd?"). MID = ( "This reference image is a stadium grandstand: most spectators are seated, and about forty are " "standing with both arms raised straight above their heads. Redraw it EXACTLY — the same crowd, " "the same people in the same seats, the same clothes, hair, hats and colours, the same rows, " "railing, empty seats and stairways, the same lighting, pixel for pixel — WITH ONE CHANGE: " "every spectator who is standing with their arms up — all of them in the lower two thirds of " "the picture — has now brought their HANDS TOGETHER IN " "FRONT OF THEIR FACE at chin height, elbows bent and pointing out to the sides, in the middle of " "a clap. They are the same standing people in the same places; only their arms have come down " "and in. Their hands and elbows still rise above the row in front of them. " "EVERYONE WHO WAS SEATED IS COMPLETELY UNCHANGED, identical to the reference. " + STYLE ) CLAP = ( "This reference image is a stadium grandstand full of seated spectators. Redraw it EXACTLY — the " "same crowd, the same people in the same seats, the same clothes, hair, hats and colours, the " "same rows, railing, empty seats and stairways, the same lighting, pixel for pixel — WITH ONE " "CHANGE: about sixty of the spectators are now ON THEIR FEET APPLAUDING with both arms raised " "above their heads, hands together, clapping. " "EVERY ONE OF THEM IS IN THE LOWER TWO THIRDS OF THE PICTURE — the rows nearest the court, " "below the horizontal railing — spread evenly from the left edge to the right edge. NOBODY in " "the top third of the picture stands up; those rows are exactly as they are in the reference. " "Each of those sixty is the SAME PERSON as in the reference — same face, same hair, same " "clothing — just standing and reaching up, so their head and hands rise above the row in front. " "EVERYONE ELSE IN THE PICTURE IS COMPLETELY UNCHANGED, identical to the reference. " "The three grey-green zombie spectators do NOT move and do NOT applaud. " + STYLE ) def u(p): return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode() def roll(prompt, ref, out): r = requests.post('https://openrouter.ai/api/v1/images', headers={'Authorization': f'Bearer {key()}', 'Content-Type': 'application/json'}, # 4K and 16:9 spelled out: the endpoint defaults to 1K and snaps the aspect to an enum. # 16:9 is 1.78 against the sheet's 1.83 — a 3% squeeze on the way down, invisible at the # size the stand is drawn. json={'model': MODEL, 'prompt': prompt, 'resolution': '4K', 'aspect_ratio': '16:9', 'input_references': [{'type': 'image_url', 'image_url': {'url': u(ref)}}]}, timeout=1800) if r.status_code != 200: sys.exit(f'HTTP {r.status_code} {r.text[:300]}') d = r.json().get('data') or [] if not d: sys.exit('no image returned') b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1] raw = out.replace('.png', '_raw.png') open(raw, 'wb').write(base64.b64decode(b)) im = Image.open(raw).convert('RGB') # BOX, not NEAREST: coming DOWN from 4K, averaging is what keeps the fine detail the prompt paid # for. The sheet is nearest-neighboured on the way back up by the game, which is the only place # hard pixel edges matter. im.resize(SIZE, Image.BOX).save(out) print(f'{out} (roll was {im.size})', flush=True) if __name__ == '__main__': os.makedirs(OUT, exist_ok=True) what = sys.argv[1] if len(sys.argv) > 1 else 'seat' if what == 'seat': # the layout reference defaults to the seated master in the repo, so a re-roll keeps the # stand's geometry rather than inventing a new one every time ref = sys.argv[2] if len(sys.argv) > 2 else os.path.join(HERE, '_sheet_crowd3.png') roll(SEAT, ref, os.path.join(OUT, 'crowd_seat.png')) elif what == 'clap': roll(CLAP, os.path.join(HERE, '_sheet_crowd3.png'), os.path.join(OUT, 'crowd_clap.png')) elif what == 'mid': roll(MID, os.path.join(HERE, '_sheet_crowd3b.png'), os.path.join(OUT, 'crowd_mid.png')) else: sys.exit('usage: _crowdroll.py seat|clap|mid')