The flat cut-outs were the cheapest content on the board and the only tier with a shipping consumer, but wardrobegod couldn't show one: scan() filtered PNGs out of garments/ (fixed in Phase 0) and there was no compositor, no slots, no preview. Now there is a working dress-up. · library/doll/ seeded with 90sDJsim's 441 shipped sprites (165 top, 110 bottom, 60 shoes, 57 hat, base body) — an instant starter wardrobe rather than a cold start. · tools/doll.py wraps djsim's compositor. It deliberately does NOT import doll_composite: that module pulls numpy at import time for a keyer and an HSV recolour we never use (the farm's RMBG-2.0 does our keying), and numpy isn't installable here under PEP 668. Instead the measured anchors are parsed out of its source with ast, so there is still exactly one source of truth and they cannot drift; place() is a pure-PIL port. · slugify() reproduces djsim's item_art() _art_slug character for character, verified against it. That equality IS the drop-in contract — a near-miss silently falls back to generic slot art instead of erroring, so it's the kind of bug you'd ship without noticing. · /api/doll/gen runs the proven pipeline: flux_local on SOLID GREEN -> farm RMBG-2.0 -> staged to 704x1408. Green is not a preference: grey and checkerboard backgrounds eat garments in the key, and flux can't spell, so no text ever goes in the prompt. · /api/doll/compose composes server-side with PIL, so the browser preview and the exported PNG are the same bytes rather than two renderers that drift. · /api/doll/export writes into a LIVE game directory, so it defaults to a dry run and refuses any stem without _i_ — overwriting a generic slot fallback would restyle every unarted item in a shipping game. · UI: a doll 2D tab with per-slot pickers, randomise, strip, and export. Verified: composed a dressed doll from real sprites (correct base->bottom->shoes->top->hat order), catalogue reports 392 layers, export dry-run and the generic-art guard both behave. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
51 lines
2.5 KiB
Python
51 lines
2.5 KiB
Python
#!/usr/bin/env python3
|
|
"""flux.mjs drop-in for the M3 Ultra MODELBEAST box — same TSV manifests, $0, no daily cap.
|
|
usage: python3 tools/flux_local.py manifest.tsv outdir [variants=1]
|
|
env: FLUX_W/FLUX_H (default 1024x1024), MB_HOST (default http://100.89.131.57:8777),
|
|
FLUX_MODEL (default flux2-klein-4b), FLUX_STEPS (default 4)
|
|
Writes outdir/<name>_<v>.jpg, skips existing — identical contract to flux.mjs, so every
|
|
existing art_*.tsv runs unchanged. Seed = stable hash of name+variant → reproducible.
|
|
"""
|
|
import json, os, sys, time, urllib.request, hashlib
|
|
|
|
mani, outdir = sys.argv[1], sys.argv[2]
|
|
variants = int(sys.argv[3]) if len(sys.argv) > 3 else 1
|
|
HOST = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
|
|
W, H = int(os.environ.get('FLUX_W', 1024)), int(os.environ.get('FLUX_H', 1024))
|
|
MODEL = os.environ.get('FLUX_MODEL', 'flux2-klein-4b')
|
|
STEPS = int(os.environ.get('FLUX_STEPS', 4))
|
|
os.makedirs(outdir, exist_ok=True)
|
|
|
|
def api(path, data=None, raw=False):
|
|
req = urllib.request.Request(HOST + path,
|
|
data=json.dumps(data).encode() if data is not None else None,
|
|
headers={'Content-Type': 'application/json'} if data is not None else {})
|
|
with urllib.request.urlopen(req, timeout=120) as r:
|
|
return r.read() if raw else json.loads(r.read())
|
|
|
|
rows = [l.rstrip('\n').split('\t', 1) for l in open(mani)
|
|
if l.strip() and not l.startswith('#')]
|
|
made = failed = 0
|
|
for name, prompt in rows:
|
|
for v in range(1, variants + 1):
|
|
out = os.path.join(outdir, f'{name}_{v}.jpg')
|
|
if os.path.exists(out): print('skip', out); continue
|
|
seed = int(hashlib.sha1(f'{name}_{v}'.encode()).hexdigest()[:7], 16)
|
|
try:
|
|
job = api('/api/jobs', {'operator': 'flux_local', 'asset_id': None,
|
|
'params': {'prompt': prompt, 'model': MODEL, 'steps': STEPS,
|
|
'width': W, 'height': H, 'seed': seed}})['id']
|
|
while True:
|
|
j = api(f'/api/jobs/{job}')
|
|
if j['status'] in ('done', 'error', 'cancelled'): break
|
|
time.sleep(2)
|
|
if j['status'] != 'done':
|
|
raise RuntimeError(j.get('log', '')[-200:])
|
|
outs = [a for a in api('/api/assets') if a.get('parent_job') == job]
|
|
img = api(f"/api/assets/{outs[0]['id']}/file", raw=True)
|
|
open(out, 'wb').write(img)
|
|
made += 1; print('made', out, flush=True)
|
|
except Exception as e:
|
|
failed += 1; print('FAIL', f'{name}_{v}:', str(e)[:180], flush=True)
|
|
print(f'done: {made} made, {failed} failed, {len(rows)} prompts')
|