wardrobegod/server.py
type-two 5c80e65d58 Salvage the SD lane: patch MODELBEAST, add the SD backend, fix the assets filter at source
The LoRA bench concluded the SD1.5 lane was invalid for garments. That was true of the
CHECKPOINT, not the lane: Hyper_Realism_1.2 was the only one the farm could reach and it is a
person-photoreal merge that framed every garment as worn-and-cropped no matter how hard you
negative-prompted. Given four non-person checkpoints, the lane works — and now beats flux.

MODELBEAST patches (John's explicit blessing; flux_local deliberately untouched because a
parallel session owned it and had already deployed LoRA + Z-Image support there):
· comfyui_sd now STACKS LoRAs. It hard-coded a single LoraLoader, so style+texture together
  was impossible; `lora` now takes a list, each entry optionally `name=0.6`, chained.
· comfyui_sd gains ControlNet (loader + ApplyAdvanced + staged control image). The payoff for
  a wardrobe is recolouring one garment into N colourways that share a byte-identical
  silhouette, so a single cut-out alpha is reusable across every variant.
· Per-node preflight widened to LoRAs and ControlNets, and the arch-mismatch warning now fires
  per LoRA. A wrong-base LoRA is a SILENT no-op, so everything fails loudly instead.
· Deployed to all three nodes that run comfyui_sd (m3ultra, ultra=m1, m4pro=m4), each backed up.
· GET /api/assets now honours ?parent_job=. It declared no such parameter, so FastAPI silently
  dropped it and every caller got the whole table — 2,578 rows for a bogus id. Callers that
  took rows[0] were right only by accident of newest-first ordering. Verified: bogus -> 0 rows,
  real -> only its own. Restarted the queue in a zero-active-jobs window.
· Copied 4 non-person checkpoints ultra -> m3ultra over the fast LAN (12.6 GB).

wardrobegod:
· `sd` backend on /api/gen, defaulting to juggernautXL_ragnarok, with checkpoint/lora/negative
  passthrough. Measured on one prompt+seed: a full isolated flat-lay where Hyper_Realism crops
  a worn garment.
· flat3d template gains 'no hanger, no coat hanger' — a wire hanger appeared in most SD product
  shots and keys into the cutout as if it were garment. doll2d already had the clause.

Verified end-to-end: 7/7 salvage cells ran (including a 2-LoRA stack, previously impossible),
and a Coogi knit generated through wardrobegod's own /api/gen came back isolated on white with
legible raised-knit texture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 18:14:21 +10:00

726 lines
39 KiB
Python

#!/usr/bin/env python3
"""WARDROBEGOD — one skeleton, infinite fits. The wardrobe generator bench.
Stdlib server + three.js page (no build step, house style — sibling of NPCFACTORY/imagelab).
python3 server.py → http://localhost:8150
WG_HOST=0.0.0.0 python3 server.py → expose on the tailnet
What it does:
· library panes over bodies / garments / generated images / finished outfits
· loads FBX or GLB on a 3D stage (FBX auto-converts to GLB via local headless Blender)
· Blender jobs: convert · scale-to-height · decimate-keep-weights · FIT (weight-transfer
a garment onto a rigged body) · ASSEMBLE (body + fitted garments → one dressed GLB)
· the garment GENERATOR pipeline (MODELBEAST, $0): flux image → bg_remove →
either a hanging-garment TEXTURE (racks/paper-doll) or trellis_mac → rigid 3D
wearable (hats/shoes/bags — bone-parent those; cloth that must bend gets FIT instead)
Env: MB_HOST (default m3ultra :8777) · MB_TOKEN (bearer; generator disabled without it)
WG_PORT (8150) · WG_HOST (127.0.0.1)
"""
import hashlib, json, mimetypes, os, re, shutil, subprocess, sys, threading, time, urllib.parse, urllib.request, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = os.path.dirname(os.path.abspath(__file__))
LIB = os.path.join(ROOT, 'library')
DIRS = {'bodies': os.path.join(LIB, 'bodies'), 'garments': os.path.join(LIB, 'garments'),
'gen': os.path.join(LIB, 'gen'), 'out': os.path.join(ROOT, 'out')}
OUTFITS = os.path.join(LIB, 'outfits') # saved dress-ups: body + attachments + clip + tints
EXTRA_BODIES = [os.path.expanduser('~/Documents/anatomy'),
os.path.expanduser('~/Documents/thriftgod/web/assets/models')]
CACHE = os.path.join(ROOT, '.glbcache')
BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender')
OPS = os.path.join(ROOT, 'blender_ops.py')
TOOLS = os.path.join(ROOT, 'tools') # reskin trio (NPCFACTORY) + the doll compositor (90sDJsim)
# Creds come off disk, never off the command line. .env beside server.py (gitignored) holds the
# CF Workers AI pair; MB_TOKEN lives in backnforth/.env — read it there rather than making every
# launch remember `export MB_TOKEN=…` (without it the farm tiers silently die: no RMBG-2.0, no
# image-edit try-on, no trellis). First file to define a key wins, so a local .env always overrides.
def _load_env(path):
if not os.path.exists(path):
return
for line in open(path):
if '=' in line and not line.lstrip().startswith('#'):
k, v = line.split('=', 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
_load_env(os.path.join(ROOT, '.env'))
_load_env(os.path.expanduser('~/Documents/backnforth/.env'))
MB = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
MB_TOKEN = os.environ.get('MB_TOKEN', '')
CF_ACCT = os.environ.get('CLOUDFLARE_ACCOUNT_ID', '')
CF_TOKEN = os.environ.get('CLOUDFLARE_API_TOKEN', '')
# 90sDJsim's doll pipeline and its live art live on ultra — export writes over the tailnet.
DJSIM_HOST = os.environ.get('WG_DJSIM_HOST', 'johnking@100.91.239.7')
DJSIM_DOLL = os.environ.get('WG_DJSIM_DOLL', 'Documents/90sDJsim/web/world/media/doll')
PORT = int(os.environ.get('WG_PORT', 8150))
HOST = os.environ.get('WG_HOST', '127.0.0.1')
for d in list(DIRS.values()) + [CACHE, OUTFITS]:
os.makedirs(d, exist_ok=True)
sys.path.insert(0, os.path.join(ROOT, 'tools'))
try: # the 2D paper-doll tier (PIL + numpy); 3D still works without it
import doll as DOLL
except Exception as _e:
DOLL = None
print(f'2D doll tier off ({_e.__class__.__name__}: {_e}) — pip install pillow numpy to enable')
JOBS = {} # id → {status, note, out, log}
MODEL_EXT = ('.glb', '.gltf', '.fbx', '.obj')
IMG_EXT = ('.png', '.jpg', '.jpeg', '.webp')
def slug(s):
return re.sub(r'[^a-z0-9]+', '-', (s or '').lower()).strip('-')[:60] or 'x'
def allowed(path):
p = os.path.realpath(path)
roots = list(DIRS.values()) + EXTRA_BODIES + [CACHE, OUTFITS, os.path.join(LIB, 'doll')]
return any(p.startswith(os.path.realpath(r) + os.sep) or p == os.path.realpath(r) for r in roots)
def scan():
out = {}
for key, d in DIRS.items():
# garments hold BOTH kinds: fitted/harvested GLBs and flat cut-out PNGs (the paper-doll
# tier). Listing models only used to hide every /api/hangable output — the whole 2D route
# dead-ended in a directory the UI refused to show.
exts = MODEL_EXT + IMG_EXT if key == 'garments' else MODEL_EXT if key in ('bodies', 'out') else IMG_EXT
rows = []
dirs = [d] + (EXTRA_BODIES if key == 'bodies' else [])
for dd in dirs:
if not os.path.isdir(dd):
continue
for f in sorted(os.listdir(dd)):
if f.lower().endswith(exts):
p = os.path.join(dd, f)
rows.append({'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024,
'home': os.path.basename(dd),
'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'})
out[key] = rows
out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json'))
return out
def doll_catalogue():
return DOLL.catalogue() if DOLL else {}
# ---------------------------------------------------------------------------------------
# Prompt templates. `doll2d` is the one that actually shipped the 441 sprites; the green
# variant is 90sDJsim's later gen_wardrobe fallback. Rules baked in, do not "improve":
# · no text/words/logos — flux can't spell, so band tees are ART, never readable words.
# · green is for PLAID and busy patterns. A bench run measured klein-4b on pure white
# keying cleanly (7/7 flood seeds) on both a dark solid and a busy multicolour knit, so
# white is the default and green is the escape hatch, not the other way round.
TEMPLATES = {
# 'no hanger, no coat hanger' is not padding: without it a wire hanger showed up in most
# SD product shots (and some flux ones), and a hanger keys into the cutout as garment.
'flat3d': ('product photo of a single {phrase} laid flat on a plain white background, front view, '
'no mannequin, no person, no hanger, no coat hanger, no text, soft even lighting, '
'1990s Australian op-shop garment, slightly worn'),
'doll2d': ('paper doll clothing item for a dress-up game, {phrase}, {year} australia, {view}, '
'floating centered on a plain pure white background, painterly stylized 3d game '
'illustration, full item visible, no body, no person, no mannequin, no hanger, '
'no coat hanger, no text, no logos'),
'doll2d_green': ('1990s Australian {phrase}, product photo, flat lay, centered, '
'solid bright green background, no text, no words, no logos, no person, no hanger'),
}
VIEWS = {'hat': 'front view', 'top': 'front view laid flat with the sleeves down',
'bottom': 'front view laid perfectly flat on the ground',
'shoes': 'the pair side by side, front view', 'bag': 'front view'}
DOLL_PROMPT = TEMPLATES['doll2d_green'] # back-compat alias
FLAT_PROMPT = TEMPLATES['flat3d']
# The SD lane's default. Hyper_Realism was the only checkpoint the farm could reach and it is a
# person-photoreal merge — it framed every garment as worn-and-cropped no matter how hard you
# negative-prompted. juggernautXL is a product/material specialist; measured on the same
# prompt+seed it returns a clean isolated flat-lay.
SD_CKPT = os.environ.get('WG_SD_CKPT', 'juggernautXL_ragnarok.safetensors')
SD_NEG = ('person, human, body, model, mannequin, face, hands, legs, worn by someone, '
'hanger, coat hanger, (worst quality, low quality:1.4), blurry, watermark, text')
def gen_seed(phrase, variant=0):
"""Reproducible seed from the phrase — reroll by bumping `variant`.
Replaces two broken schemes: /api/gen used `time.time() % 99991` (nothing was ever
reproducible) and /api/doll/gen inherited the operator default seed=42 (the same phrase
produced a byte-identical image forever, so there was no reroll at all).
"""
return int(hashlib.sha1(f'{phrase}_{variant}'.encode()).hexdigest()[:7], 16)
def run_blender(args, jid=None, script=None):
"""One headless Blender op; stdout tail lands in the job log (when there is a job).
script= runs a different bpy file (the reskin trio lifted from NPCFACTORY) instead of
blender_ops.py.
"""
cmd = [BLENDER, '-b', '--python', script or OPS, '--'] + [str(a) for a in args]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
tail = '\n'.join((r.stdout + r.stderr).strip().splitlines()[-12:])
if jid and jid in JOBS:
JOBS[jid]['log'] = tail
if r.returncode != 0 or 'Error' in r.stderr:
raise RuntimeError(tail[-400:])
def glb_of(path, jid=None):
"""FBX/OBJ → cached GLB (stage + ops always speak GLB); GLB passes through.
The cache key carries a hash of the full source path: keying on the basename alone let
two different files that slug the same (anatomy/jacket_v2.fbx vs garments/jacket-v2.fbx)
collide onto one cache entry and serve each other's geometry.
"""
if path.lower().endswith(('.glb', '.gltf')):
return path
src = os.path.realpath(path)
key = slug(os.path.basename(path)) + '-' + hashlib.sha1(src.encode()).hexdigest()[:8]
tgt = os.path.join(CACHE, key + '.glb')
if not os.path.exists(tgt) or os.path.getmtime(tgt) < os.path.getmtime(path):
run_blender(['convert', path, tgt], jid)
return tgt
def new_job(kind, note):
reap()
jid = uuid.uuid4().hex[:10]
JOBS[jid] = {'status': 'running', 'kind': kind, 'note': note, 'out': None, 'log': '', 't': time.time()}
return jid
def reap(max_age=3600, keep=200):
"""Drop finished jobs the page has long stopped polling — JOBS never pruned itself."""
now = time.time()
dead = [k for k, v in list(JOBS.items())
if v.get('status') in ('done', 'error') and now - v.get('t', now) > max_age]
for k in dead:
JOBS.pop(k, None)
if len(JOBS) > keep:
for k, _ in sorted(JOBS.items(), key=lambda kv: kv[1].get('t', 0))[:len(JOBS) - keep]:
JOBS.pop(k, None)
def job_thread(jid, fn):
def go():
try:
JOBS[jid]['out'] = fn(jid)
JOBS[jid]['status'] = 'done'
except Exception as e:
JOBS[jid]['status'] = 'error'
JOBS[jid]['log'] = (JOBS[jid]['log'] + '\n' + str(e)).strip()[-1500:]
threading.Thread(target=go, daemon=True).start()
# ---------- MODELBEAST client (token stays server-side, never in the page) ----------
def mb_req(path, data=None, raw=None, ctype='application/json'):
req = urllib.request.Request(MB + path, data=raw if raw is not None else (json.dumps(data).encode() if data else None))
req.add_header('Authorization', 'Bearer ' + MB_TOKEN)
if data is not None or raw is not None:
req.add_header('Content-Type', ctype)
with urllib.request.urlopen(req, timeout=120) as r:
body = r.read()
try:
return json.loads(body)
except ValueError:
return body
def mb_wait(job_id, jid, timeout=900):
t0 = time.time()
while time.time() - t0 < timeout:
j = mb_req(f'/api/jobs/{job_id}')
st = j.get('status')
JOBS[jid]['note'] = f"farm: {st}"
if st in ('done', 'completed', 'succeeded'):
return j
if st in ('error', 'failed', 'cancelled'):
raise RuntimeError('farm job failed: ' + str(j.get('error') or st))
time.sleep(3)
raise RuntimeError('farm job timed out')
def mb_outputs(job_id):
"""Output assets for one farm job.
The farm IGNORES ?parent_job= — a bogus id still returns the whole asset table (measured:
2,578 rows, 34 distinct parents). Taking rows[0] only ever worked because the table comes
back newest-first, so two jobs in flight would happily download each other's image. Always
filter client-side. job.asset_ids is [] even on success, so it's no help either.
"""
assets = mb_req(f'/api/assets?parent_job={job_id}')
rows = assets.get('assets', assets) if isinstance(assets, dict) else assets
if isinstance(rows, dict):
rows = rows.get('items', [])
return [r for r in (rows or []) if isinstance(r, dict) and str(r.get('parent_job')) == str(job_id)]
def mb_download(asset, tgt):
aid = asset.get('id') or asset.get('asset_id')
data = mb_req(f'/api/assets/{aid}/file')
if not isinstance(data, bytes):
# mb_req parses any JSON-looking 200 into a dict. Writing that dict into a .png/.glb
# used to succeed silently and mark the job done — you got a "mesh" full of error JSON.
raise RuntimeError(f'farm returned no file for asset {aid}: {str(data)[:200]}')
with open(tgt, 'wb') as f:
f.write(data)
return tgt
def mb_upload(path):
import mimetypes as mt
boundary = uuid.uuid4().hex
name = os.path.basename(path)
ctype = mt.guess_type(path)[0] or 'application/octet-stream'
with open(path, 'rb') as f:
payload = f.read()
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
f'Content-Type: {ctype}\r\n\r\n').encode() + payload + f'\r\n--{boundary}--\r\n'.encode()
out = mb_req('/api/assets', raw=body, ctype=f'multipart/form-data; boundary={boundary}')
return out.get('id') or out.get('asset_id') or (out.get('asset') or {}).get('id')
# ---------- HTTP ----------
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def send(self, code, body, ctype='application/json'):
self.send_response(code)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def j(self, obj, code=200):
self.send(code, json.dumps(obj).encode())
def do_GET(self):
u = urllib.parse.urlparse(self.path)
q = dict(urllib.parse.parse_qsl(u.query))
if u.path == '/':
return self.send(200, open(os.path.join(ROOT, 'web', 'index.html'), 'rb').read(), 'text/html')
if u.path == '/api/lib':
return self.j({'lib': scan(), 'mb': bool(MB_TOKEN), 'cf': bool(CF_ACCT and CF_TOKEN),
'blender': os.path.exists(BLENDER), 'doll': doll_catalogue()})
if u.path.startswith('/api/job/'):
jid = u.path.rsplit('/', 1)[1]
return self.j(JOBS.get(jid) or {'status': 'unknown'})
if u.path.startswith('/api/outfit/'):
f = os.path.join(OUTFITS, slug(u.path.rsplit('/', 1)[1]) + '.json')
if not os.path.isfile(f):
return self.j({'error': 'no such outfit'}, 404)
return self.j(json.load(open(f)))
if u.path == '/file':
p = q.get('p', '')
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'nope'}, 403)
return self.send(200, open(p, 'rb').read(), mimetypes.guess_type(p)[0] or 'application/octet-stream')
if u.path == '/glb': # stage loader: any model file → GLB (convert+cache)
p = q.get('p', '')
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'nope'}, 403)
try:
g = glb_of(p)
except Exception as e:
return self.j({'error': str(e)[-300:]}, 500)
return self.send(200, open(g, 'rb').read(), 'model/gltf-binary')
return self.j({'error': 'not found'}, 404)
def do_POST(self):
u = urllib.parse.urlparse(self.path)
q = dict(urllib.parse.parse_qsl(u.query))
n = int(self.headers.get('Content-Length') or 0)
raw = self.rfile.read(n) if n else b''
if u.path == '/api/upload': # file input → library dir
to, name = q.get('to', 'bodies'), os.path.basename(q.get('name', 'upload.glb'))
if to not in DIRS or not name.lower().endswith(MODEL_EXT + IMG_EXT):
return self.j({'error': 'bad target'}, 400)
p = os.path.join(DIRS[to], name)
with open(p, 'wb') as f:
f.write(raw)
return self.j({'ok': True, 'path': p})
body = json.loads(raw or b'{}')
if u.path == '/api/outfit': # save a dress-up: body + attachments + clip + tints
# The rigid-attach sliders used to live only in browser memory ("preview only") — every
# hat placement died on reload. This is the persistence half of that tier.
name = slug(body.get('name') or 'outfit')
b = body.get('body') or ''
if not allowed(b) or not os.path.isfile(b):
return self.j({'error': 'body outside library'}, 403)
rec = {'name': name, 'body': b, 'anim': body.get('anim'),
'attach': body.get('attach') or {}, 'tint': body.get('tint') or {},
'saved': time.strftime('%Y-%m-%dT%H:%M:%S')}
for att in rec['attach'].values():
if not allowed(att.get('path', '')):
return self.j({'error': 'attachment outside library'}, 403)
with open(os.path.join(OUTFITS, name + '.json'), 'w') as f:
json.dump(rec, f, indent=2)
return self.j({'ok': True, 'name': name})
if u.path == '/api/reskin': # 3D tier: paint a garment onto the body's own UVs
# Sidesteps the fact that every TRELLIS body is unstructured with no shared topology —
# instead of fitting a cloth mesh, we repaint the atlas the body already has. Needs a
# single-mesh / single-UV / single-material body (multi-material rigs shard the bake).
if not MB_TOKEN:
return self.j({'error': 'reskin needs the farm image-edit operator — MB_TOKEN missing'}, 400)
b, g = body.get('body', ''), body.get('garment', '')
for p in (b, g):
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'path outside library'}, 403)
desc = body.get('desc') or 'the garment in image 2'
jid = new_job('reskin', 'reskin ' + os.path.basename(b))
def fx(jid):
src = glb_of(b, jid)
name = slug(os.path.basename(b).rsplit('.', 1)[0]) + '--' + slug(os.path.basename(g).rsplit('.', 1)[0])
wd = os.path.join(DIRS['gen'], '_reskin', name)
os.makedirs(wd, exist_ok=True)
JOBS[jid]['note'] = 'rendering bind-pose plates'
run_blender([src, wd], jid, script=os.path.join(TOOLS, 'render_plates.py'))
gid = mb_upload(g)
outs = {}
for side in ('front', 'back'):
plate = os.path.join(wd, f'base_{side}.png')
if not os.path.exists(plate):
raise RuntimeError(f'no {side} plate rendered')
JOBS[jid]['note'] = f'image-edit: dressing the {side} plate'
j = mb_req('/api/jobs', {
'operator': 'mflux_image_edit',
'asset_ids': [mb_upload(plate), gid],
'params': {'prompt': f'put {desc} from image 2 onto the person in image 1. '
'Keep the exact same pose, body proportions, framing and '
'plain background. Full body visible, head to toe.'}})
ji = j.get('id') or j.get('job_id')
mb_wait(ji, jid, timeout=900)
o = mb_outputs(ji)
if not o:
raise RuntimeError(f'image-edit returned nothing for the {side} plate')
edited = os.path.join(wd, f'edit_{side}.png')
mb_download(o[0], edited)
# the edit model redraws the figure at its own scale/offset; the projection bake
# assumes the original framing, so unaligned plates make the body sample background
aligned = os.path.join(wd, f'aligned_{side}.png')
subprocess.run(['python3', os.path.join(TOOLS, 'align_plate.py'), plate, edited, aligned],
capture_output=True, text=True, timeout=300)
outs[side] = aligned if os.path.exists(aligned) else edited
JOBS[jid]['note'] = 'baking onto the body atlas (Cycles)'
tgt = os.path.join(DIRS['garments'], name + '.reskin.png')
run_blender([src, outs['front'], outs['back'], tgt], jid,
script=os.path.join(TOOLS, 'bake_skin.py'))
if not os.path.exists(tgt):
raise RuntimeError('bake produced no texture')
return tgt
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/doll/gen': # 2D tier: prompt → staged paper-doll layer
if not DOLL:
return self.j({'error': '2D tier unavailable — needs pillow + numpy'}, 400)
if not MB_TOKEN:
return self.j({'error': 'the doll pipeline runs on the farm — MB_TOKEN missing'}, 400)
slot, keyname = body.get('slot', 'top'), body.get('key') or body.get('phrase', '')
phrase = body.get('phrase', '')
if slot not in ('hat', 'top', 'bottom', 'shoes', 'bag'):
return self.j({'error': 'slot must be hat|top|bottom|shoes|bag'}, 400)
if not phrase:
return self.j({'error': 'need a garment phrase'}, 400)
# doll2d (painterly, pure white) is the template the 441 shipped sprites were made
# with; doll2d_green is the escape hatch for plaid and busy patterns where a white
# key gets confused. Default to what shipped.
tpl = body.get('template', 'doll2d')
if tpl not in TEMPLATES:
return self.j({'error': 'template must be one of ' + '|'.join(TEMPLATES)}, 400)
variant = int(body.get('variant', 0) or 0)
style = TEMPLATES[tpl].format(phrase=phrase, year=body.get('year', 1990),
view=VIEWS.get(slot, 'front view'))
jid = new_job('doll', f'{slot}: {phrase[:40]}')
JOBS[jid]['prompt'] = style
def fx(jid):
stage_dir = os.path.join(DIRS['gen'], '_doll')
os.makedirs(stage_dir, exist_ok=True)
base = DOLL.slugify(keyname)
raw = os.path.join(stage_dir, f'{slot}_{base}_flux.png')
cut = os.path.join(stage_dir, f'{slot}_{base}_keyed.png')
JOBS[jid]['note'] = f'flux ({tpl})'
# 704, not 1024: the doll canvas is 704 wide, so generating bigger only means
# downscaling before the key and softening the edges we depend on.
j = mb_req('/api/jobs', {'operator': 'flux_local',
'params': {'prompt': style, 'model': 'flux2-klein-4b',
'steps': 4, 'width': 704, 'height': 704,
'seed': gen_seed(phrase, variant)}})
gid = j.get('id') or j.get('job_id')
mb_wait(gid, jid)
outs = mb_outputs(gid)
if not outs:
raise RuntimeError('flux returned no image')
mb_download(outs[0], raw)
JOBS[jid]['note'] = 'keying (farm RMBG-2.0)'
k = mb_req('/api/jobs', {'operator': 'bg_remove_local', 'asset_id': mb_upload(raw),
'params': {}})
kid = k.get('id') or k.get('job_id')
mb_wait(kid, jid)
kouts = mb_outputs(kid)
if not kouts:
raise RuntimeError('bg_remove returned nothing')
mb_download(kouts[0], cut)
JOBS[jid]['note'] = 'staging to 704x1408'
return DOLL.stage(cut, slot, base)
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/doll/compose': # stack staged layers → one dressed doll preview
if not DOLL:
return self.j({'error': '2D tier unavailable'}, 400)
stems = [s for s in (body.get('stems') or []) if isinstance(s, str) and '/' not in s]
out = os.path.join(DIRS['out'], slug(body.get('name') or 'doll') + '.png')
base = os.path.join(DOLL.DOLL_DIR, 'base.png')
DOLL.compose(out, stems, base=base if os.path.exists(base) else None)
return self.j({'ok': True, 'path': out})
if u.path == '/api/doll/export': # staged layers → 90sDJsim's live media/doll
if not DOLL:
return self.j({'error': '2D tier unavailable'}, 400)
stems = [s for s in (body.get('stems') or []) if isinstance(s, str) and '/' not in s]
if not stems:
return self.j({'error': 'nothing to export'}, 400)
# djsim's loader resolves <slot>_i_<slug>.png and self-heals on new files, so a plain
# copy wires the garment with no code change. NEVER touch the generic slot fallbacks —
# overwriting those would restyle every unarted item in a shipping game.
bad = [s for s in stems if '_i_' not in s]
if bad:
return self.j({'error': 'refusing to overwrite generic slot art: ' + bad[0]}, 400)
files = [(s, os.path.join(DOLL.DOLL_DIR, s + '.png')) for s in stems]
missing = [s for s, p in files if not os.path.isfile(p)]
if missing:
return self.j({'error': 'not staged: ' + missing[0]}, 404)
dest = f'{DJSIM_HOST}:{DJSIM_DOLL}'
if body.get('dry', True):
return self.j({'ok': True, 'dry': True, 'files': [s for s, _ in files],
'dest': dest,
'note': f'DRY RUN — would copy {len(files)} layer/s to {dest}. '
f'Re-send with dry:false to actually write.'})
jid = new_job('export', f'{len(files)} layer/s → djsim')
def fx(jid):
cmd = ['rsync', '-a'] + [p for _, p in files] + [dest + '/']
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if r.returncode != 0:
raise RuntimeError((r.stderr or r.stdout)[-400:])
return dest
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/blender': # {op, args:{...}} → background Blender job
op = body.get('op')
a = body.get('args', {})
# Every other endpoint gates its path args through allowed(); this one never did, so
# any tailnet peer could hand WG_HOST=0.0.0.0 an arbitrary on-disk file to import.
ins = [a[k] for k in ('path', 'body', 'garment') if a.get(k)] + list(a.get('garments') or [])
if not ins:
return self.j({'error': 'no input path'}, 400)
for p in ins:
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'path outside library: ' + os.path.basename(str(p))}, 403)
jid = new_job(op, body.get('note', op))
def fx(jid):
if op == 'scale':
src = glb_of(a['path'], jid)
out = os.path.join(DIRS['bodies'], slug(a.get('name') or os.path.basename(a['path']).rsplit('.', 1)[0]) + '.glb')
run_blender(['scale', src, out, a['height']], jid)
elif op == 'decimate':
src = glb_of(a['path'], jid)
out = os.path.join(DIRS['bodies'], slug(os.path.basename(a['path']).rsplit('.', 1)[0]) + f"-{int(a['tris'])//1000}k.glb")
run_blender(['decimate', src, out, int(a['tris'])], jid)
elif op == 'fit':
b, g = glb_of(a['body'], jid), glb_of(a['garment'], jid)
mode = a.get('mode', 'garment')
base = slug(os.path.basename(a['garment']).rsplit('.', 1)[0])
out = os.path.join(DIRS['garments'] if mode == 'garment' else DIRS['out'],
base + ('-fitted.glb' if mode == 'garment' else '-dressed.glb'))
run_blender(['fit', b, g, out, mode, a.get('inflate', 3)], jid)
elif op == 'assemble':
b = glb_of(a['body'], jid)
gs = [glb_of(g, jid) for g in a['garments']]
out = os.path.join(DIRS['out'], slug(a.get('name') or 'outfit') + '.glb')
run_blender(['assemble', b, out] + gs, jid)
elif op == 'convert':
src = a['path']
out = os.path.join(DIRS['bodies'], slug(os.path.basename(src).rsplit('.', 1)[0]) + '.glb')
run_blender(['convert', src, out], jid)
else:
raise RuntimeError('unknown op')
return out
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/gen': # the garment pipeline, step 1: flux image
if not (CF_ACCT and CF_TOKEN) and not MB_TOKEN:
return self.j({'error': 'no image backend — need .env CF creds or MB_TOKEN'}, 400)
phrase = body.get('phrase') or body.get('prompt', '')
if not phrase:
return self.j({'error': 'need a garment phrase'}, 400)
tpl = body.get('template', 'flat3d')
if tpl not in TEMPLATES:
return self.j({'error': 'template must be one of ' + '|'.join(TEMPLATES)}, 400)
# klein-4b is the DEFAULT because it measurably renders fabric better: at matched
# prompt/seed it holds corduroy wale across the garment where CF flux-1-schnell
# flattens it to smooth canvas. CF stays available — it's ~2s and never queues
# behind the farm's serial GPU lane, which is worth real money on a one-at-a-time UI.
backend = body.get('backend', 'klein' if MB_TOKEN else 'cf')
variant = int(body.get('variant', 0) or 0)
seed = body.get('seed')
seed = gen_seed(phrase, variant) if seed in (None, '') else int(seed)
wide = int(body.get('width') or (704 if tpl.startswith('doll2d') else 1024))
steps = int(body.get('steps') or 4)
style = TEMPLATES[tpl].format(phrase=phrase, year=body.get('year', 1990),
view=VIEWS.get(body.get('view', 'top'), 'front view'))
if body.get('lora') and backend != 'sd':
pass # flux_local has no lora param at all — silently ignoring beats pretending
jid = new_job('gen', f'{backend}: {phrase[:36]}')
JOBS[jid]['prompt'] = style
JOBS[jid]['seed'] = seed
def fx(jid):
tgt = os.path.join(DIRS['gen'], f'{slug(phrase)}-v{variant}.png')
if backend == 'cf' and CF_ACCT and CF_TOKEN:
import base64
JOBS[jid]['note'] = 'cloudflare flux-1-schnell (softer fabric)'
req = urllib.request.Request(
f'https://api.cloudflare.com/client/v4/accounts/{CF_ACCT}/ai/run/@cf/black-forest-labs/flux-1-schnell',
data=json.dumps({'prompt': style, 'steps': max(steps, 8),
'width': wide, 'height': wide, 'seed': seed}).encode(),
headers={'Authorization': 'Bearer ' + CF_TOKEN, 'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=120) as r:
out = json.load(r)
if not out.get('success'):
raise RuntimeError(str(out.get('errors'))[:300])
with open(tgt, 'wb') as f:
f.write(base64.b64decode(out['result']['image']))
return tgt
if backend == 'sd':
# SDXL via ComfyUI. juggernautXL is a product/material specialist and beats
# the SD1.5 default outright: measured side by side on the same prompt+seed it
# gives a full isolated flat-lay, where Hyper_Realism frames the garment as
# worn and crops it at the edge. `lora` may be a list — the operator chains them.
JOBS[jid]['note'] = 'comfyui_sd ' + body.get('checkpoint', SD_CKPT)
params = {'prompt': style, 'negative': body.get('negative', SD_NEG),
'checkpoint': body.get('checkpoint', SD_CKPT),
'steps': int(body.get('steps') or 25), 'cfg': float(body.get('cfg', 7.0)),
'width': wide, 'height': wide, 'seed': seed}
if body.get('lora'):
params['lora'] = body['lora']
params['lora_weight'] = body.get('lora_weight', 0.8)
j = mb_req('/api/jobs', {'operator': 'comfyui_sd', 'params': params})
gid = j.get('id') or j.get('job_id')
mb_wait(gid, jid, timeout=900)
outs = mb_outputs(gid)
if not outs:
raise RuntimeError('no image came back')
return mb_download(outs[0], tgt)
JOBS[jid]['note'] = 'flux_local klein-4b'
j = mb_req('/api/jobs', {'operator': 'flux_local',
'params': {'prompt': style, 'model': 'flux2-klein-4b',
'steps': steps, 'width': wide, 'height': wide,
'seed': seed}})
gid = j.get('id') or j.get('job_id')
mb_wait(gid, jid)
outs = mb_outputs(gid)
if not outs:
raise RuntimeError('no image came back')
return mb_download(outs[0], tgt)
job_thread(jid, fx)
return self.j({'job': jid, 'seed': seed, 'prompt': style})
if u.path == '/api/rmbg': # step 2: cut it out (farm RMBG, else local white-keyer)
p = body.get('path', '')
if not allowed(p):
return self.j({'error': 'nope'}, 403)
jid = new_job('rmbg', 'cutout ' + os.path.basename(p))
def fx(jid):
tgt = p.rsplit('.', 1)[0] + '-cut.png'
if MB_TOKEN: # RMBG-2.0 on the farm — best quality
aid = mb_upload(p)
j = mb_req('/api/jobs', {'operator': 'bg_remove_local', 'asset_id': aid, 'params': {}})
mb_wait(j.get('id') or j.get('job_id'), jid)
outs = mb_outputs(j.get('id') or j.get('job_id'))
if not outs:
raise RuntimeError('no cutout came back')
return mb_download(outs[0], tgt)
# local fallback: border-flood white-keyer (never naive white→alpha — traps ledger).
# flood from the edges through near-white pixels only, so white ON the garment survives.
# Measured limit: flat3d product shots come back with a studio-grey backdrop and a soft
# drop shadow (border 215-244, ZERO pixels ≥250), so this >232 gate only partially seeds
# and leaves grey fringing. RMBG-2.0 is semantic and doesn't care — warn loudly here.
JOBS[jid]['note'] = 'local white-keyer (no farm token — grey backdrops will fringe)'
from PIL import Image
im = Image.open(p).convert('RGBA')
w, h = im.size
px = im.load()
near = lambda c: c[0] > 232 and c[1] > 232 and c[2] > 232
seen = bytearray(w * h)
stack = [(x, y) for x in range(w) for y in (0, h - 1)] + \
[(x, y) for y in range(h) for x in (0, w - 1)]
while stack:
x, y = stack.pop()
i = y * w + x
if seen[i] or not near(px[x, y]):
continue
seen[i] = 1
px[x, y] = (0, 0, 0, 0)
if x > 0: stack.append((x - 1, y))
if x < w - 1: stack.append((x + 1, y))
if y > 0: stack.append((x, y - 1))
if y < h - 1: stack.append((x, y + 1))
im.save(tgt)
return tgt
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/to3d': # step 3a: rigid wearable via TRELLIS (hats/shoes/bags)
if not MB_TOKEN:
return self.j({'error': 'MB_TOKEN not set'}, 400)
p = body.get('path', '')
if not allowed(p):
return self.j({'error': 'nope'}, 403)
jid = new_job('to3d', '3D: ' + os.path.basename(p) + ' (~5 min on the farm)')
def fx(jid):
aid = mb_upload(p)
j = mb_req('/api/jobs', {'operator': 'trellis_mac', 'asset_id': aid, 'params': {}})
mb_wait(j.get('id') or j.get('job_id'), jid, timeout=1200)
outs = mb_outputs(j.get('id') or j.get('job_id'))
glbs = [o for o in outs if str(o.get('name', o.get('filename', ''))).lower().endswith('.glb')] or outs
if not glbs:
raise RuntimeError('no mesh came back')
tgt = os.path.join(DIRS['garments'], slug(os.path.basename(p).rsplit('.', 1)[0]) + '-rigid.glb')
return mb_download(glbs[0], tgt)
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/hangable': # step 3b: cutout → hanging-garment texture pack
p = body.get('path', '')
if not allowed(p):
return self.j({'error': 'nope'}, 403)
tgt = os.path.join(DIRS['garments'], os.path.basename(p))
shutil.copyfile(p, tgt)
return self.j({'ok': True, 'path': tgt,
'note': 'texture saved to garments — use on rack planes (fittings.js garment({image}))'})
return self.j({'error': 'not found'}, 404)
if __name__ == '__main__':
print(f'WARDROBEGOD on http://{HOST}:{PORT} · blender={os.path.exists(BLENDER)} · farm token={"yes" if MB_TOKEN else "NO (generator off)"}')
ThreadingHTTPServer((HOST, PORT), H).serve_forever()