Painted title screen + farm batch client

Title backdrop generated on MODELBEAST (flux2-klein, 80s airbrush direction):
the possessed kit glowing in the studio murk with static gremlins circling.
Text carries its own shadow instead of a heavier scrim, so the art stays
visible behind it.

tools/mbgen.py is the reusable farm batch client — fetches each output by
parent_job, never newest-asset (concurrent jobs crossed outputs on 2026-07-16).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-26 11:09:45 +10:00
parent 97a4a01b0b
commit 8c8bb8cebb
3 changed files with 148 additions and 5 deletions

BIN
art/title.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@ -9,6 +9,9 @@ const cvs = document.getElementById('game');
const ctx = cvs.getContext('2d');
const VW = cvs.width, VH = cvs.height;
const titleArt = new Image();
titleArt.src = 'art/title.jpg';
// ---- balance ----
const BAL = {
bpm: 112,
@ -878,8 +881,20 @@ function overlay(color, big, small, prompt) {
function drawTitle() {
const t = performance.now() / 1000;
if (titleArt.complete && titleArt.naturalWidth) {
// cover-fit the painted backdrop, then knock it back so the logo reads
const s = Math.max(VW / titleArt.naturalWidth, VH / titleArt.naturalHeight);
const w = titleArt.naturalWidth * s, h = titleArt.naturalHeight * s;
ctx.drawImage(titleArt, (VW - w) / 2, (VH - h) / 2, w, h);
const g = ctx.createLinearGradient(0, 0, 0, VH);
g.addColorStop(0, 'rgba(5,6,10,.86)');
g.addColorStop(0.45, 'rgba(5,6,10,.52)');
g.addColorStop(1, 'rgba(5,6,10,.92)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, VW, VH);
}
// drifting waveform
ctx.strokeStyle = '#1c2a44';
ctx.strokeStyle = '#1c2a4499';
ctx.lineWidth = 2;
for (let k = 0; k < 3; k++) {
ctx.beginPath();
@ -900,7 +915,12 @@ function drawTitle() {
ctx.fillStyle = '#8fa3c8';
ctx.font = '15px ui-monospace, Menlo, monospace';
ctx.fillText('a possession roguelite — Ranarama × Paradroid', VW / 2, 205);
ctx.fillStyle = '#5a6a80';
// the painted backdrop is busy — text carries its own shadow rather than
// washing the art out with a heavier scrim
ctx.save();
ctx.shadowColor = '#05060a';
ctx.shadowBlur = 8;
ctx.fillStyle = '#93a9c4';
ctx.font = '13px ui-monospace, Menlo, monospace';
const lines = [
'You are a rogue SIGNAL. Hardware cannot see you. Static can.',
@ -908,14 +928,20 @@ function drawTitle() {
'Its body is your health, your ammo, your disguise. It is always dying.',
'SPACE fire (hold = gain = loud) · E eject · WASD move · M mute',
];
lines.forEach((l, i) => ctx.fillText(l, VW / 2, 300 + i * 24));
lines.forEach((l, i) => {
ctx.fillText(l, VW / 2, 300 + i * 24);
ctx.fillText(l, VW / 2, 300 + i * 24); // double pass = denser shadow
});
if (Math.floor(t * 2) % 2) {
ctx.fillStyle = '#cfe3ff';
ctx.fillStyle = '#eaf4ff';
ctx.font = 'bold 17px ui-monospace, Menlo, monospace';
ctx.fillText('PRESS ENTER', VW / 2, 440);
ctx.fillText('PRESS ENTER', VW / 2, 440);
}
ctx.fillStyle = '#3a4560';
ctx.fillStyle = '#6b7a90';
ctx.font = '13px ui-monospace, Menlo, monospace';
ctx.fillText('purge every machine · the drone resolves when the floor is clean', VW / 2, 560);
ctx.restore();
ctx.textAlign = 'left';
}

117
tools/mbgen.py Normal file
View File

@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""mbgen.py — batch text→image on the MODELBEAST farm (flux_local / comfyui_sd).
Reads a JSON job spec, submits with a concurrency cap, polls, and pulls each
output image back by `parent_job` (NEVER newest-asset: two concurrent jobs
raced and crossed outputs on 2026-07-16).
Spec: [{"out": "path.png", "operator": "flux_local", "params": {...}}, ...]
Usage: mbgen.py spec.json [--max-active 4]
Token: MB_TOKEN env, else ~/Documents/fluxgod-work/.env
"""
import json, os, sys, time, urllib.request, urllib.error
HOST = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
ENVS = ['~/Documents/fluxgod-work/.env', '~/Documents/backnforth/.env']
def token():
t = os.environ.get('MB_TOKEN')
if t:
return t
for e in ENVS:
p = os.path.expanduser(e)
if os.path.exists(p):
for line in open(p):
if line.startswith('MB_TOKEN='):
return line.split('=', 1)[1].strip().strip('"\'')
sys.exit('no MB_TOKEN')
TOK = token()
def req(path, data=None, headers=None, raw=False, timeout=180):
h = {'Authorization': f'Bearer {TOK}'}
h.update(headers or {})
r = urllib.request.Request(HOST + path, data=data, headers=h)
body = urllib.request.urlopen(r, timeout=timeout).read()
if raw:
return body
s = body.decode('utf-8', 'replace')
# job logs carry raw control chars — strip before parsing
return json.loads(''.join(c if c >= ' ' or c in '\t' else ' ' for c in s))
def submit(item):
payload = {'operator': item['operator'], 'params': item['params']}
j = req('/api/jobs', data=json.dumps(payload).encode(),
headers={'Content-Type': 'application/json'})
return j['id']
def fetch_output(jid, out):
"""Pull the image asset belonging to THIS job (parent_job match only)."""
a = req('/api/assets?limit=200')
items = a if isinstance(a, list) else a.get('items', [])
mine = [x for x in items
if x.get('parent_job') == jid
and str(x.get('name', x.get('filename', ''))).lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))]
if not mine:
return False
blob = req(f"/api/assets/{mine[0]['id']}/file", raw=True)
os.makedirs(os.path.dirname(out) or '.', exist_ok=True)
with open(out, 'wb') as f:
f.write(blob)
return True
def main():
spec = json.load(open(sys.argv[1]))
cap = 4
if '--max-active' in sys.argv:
cap = int(sys.argv[sys.argv.index('--max-active') + 1])
todo = [dict(i, _state='pending') for i in spec
if not (os.path.exists(i['out']) and os.path.getsize(i['out']) > 1000)]
skipped = len(spec) - len(todo)
if skipped:
print(f'[mb] {skipped} already done, skipping')
active, done, failed = {}, 0, []
t0 = time.time()
while todo or active:
while todo and len(active) < cap:
it = todo.pop(0)
try:
jid = submit(it)
active[jid] = it
print(f'[mb] + {os.path.basename(it["out"])}{jid[:8]}', flush=True)
except urllib.error.HTTPError as e:
if e.code in (429, 409): # queue full — retry this one later
todo.insert(0, it)
time.sleep(10)
break
failed.append((it['out'], f'submit {e.code}'))
if not active:
continue
time.sleep(6)
for jid in list(active):
try:
st = req(f'/api/jobs/{jid}').get('status')
except Exception:
continue
if st in ('done', 'error', 'cancelled'):
it = active.pop(jid)
if st == 'done' and fetch_output(jid, it['out']):
done += 1
print(f'[mb] ✓ {os.path.basename(it["out"])} ({done} done, {len(todo)} left, {int(time.time()-t0)}s)', flush=True)
else:
failed.append((it['out'], st))
print(f'[mb] ✗ {os.path.basename(it["out"])}{st}', flush=True)
print(f'\n[mb] {done} generated, {len(failed)} failed in {int(time.time()-t0)}s')
for f, why in failed:
print(f'{os.path.basename(f)}{why}')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())