#!/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, 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') # 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', '') 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) 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] 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 run_blender(args, jid=None): """One headless Blender op; stdout tail lands in the job log (when there is a job).""" cmd = [BLENDER, '-b', '--python', 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): assets = mb_req(f'/api/assets?parent_job={job_id}') rows = assets.get('assets', assets) if isinstance(assets, dict) else assets return rows or [] 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)}) 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/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) prompt = body.get('prompt', '') style = ('product photo of a single {} laid flat on a plain white background, ' 'front view, no mannequin, no person, no text, soft even lighting, ' '1990s Australian op-shop garment, slightly worn').format(prompt) jid = new_job('gen', 'flux: ' + prompt[:40]) def fx(jid): tgt = os.path.join(DIRS['gen'], slug(prompt) + '.png') if CF_ACCT and CF_TOKEN: # Cloudflare Workers AI direct — the flux.mjs contract import base64 JOBS[jid]['note'] = 'cloudflare flux-1-schnell' 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': 8, 'width': 768, 'height': 768}).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 j = mb_req('/api/jobs', {'operator': 'flux_local', 'asset_id': None, 'params': {'prompt': style, 'model': 'flux2-klein-4b', 'steps': 4, 'width': 768, 'height': 768, 'seed': int(time.time()) % 99991}}) 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 image came back') return mb_download(outs[0], tgt) job_thread(jid, fx) return self.j({'job': jid}) 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. JOBS[jid]['note'] = 'local white-keyer' 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()