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:
parent
97a4a01b0b
commit
8c8bb8cebb
BIN
art/title.jpg
Normal file
BIN
art/title.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
36
src/game.js
36
src/game.js
@ -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
117
tools/mbgen.py
Normal 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())
|
||||
Loading…
Reference in New Issue
Block a user