#!/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 THUMBS = os.path.join(ROOT, '.thumbs') # lazy 256px previews, keyed on source path hash MANIFEST = os.path.join(LIB, 'index.json') # slot / layer / tags per asset — the wardrobe's spine 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) MIRPAMO = os.environ.get('WG_MIRPAMO', os.path.expanduser('~/Documents/MIRPAMO/bin/mirpamo')) # 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' # Asset classification lives in the FILENAME, matching the convention already in use across the # fleet (~/Documents/FBX: a '-nsfw' suffix before the extension, 106 of 124 files tagged, plus an # `nsfw` column in _INVENTORY.csv). Filenames beat a sidecar database here because assets get # rsynced between six machines constantly — a tag in the name travels with the file and can't # drift out of sync, and it's visible in Finder. NSFW_RE = re.compile(r'-nsfw(?=[.\s(]|$)', re.I) def is_nsfw(path): return bool(NSFW_RE.search(os.path.basename(path))) def tagged_name(name, nsfw): """Add or remove the -nsfw suffix, preserving the extension.""" stem, dot, ext = name.rpartition('.') if not dot: stem, ext = name, '' stem = NSFW_RE.sub('', stem).rstrip('-') if nsfw: stem += '-nsfw' return stem + (dot + ext if dot else '') 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) row = {'name': f, 'path': p, 'kb': os.path.getsize(p) // 1024, 'home': os.path.basename(dd), 'nsfw': is_nsfw(f), 'kind': 'img' if f.lower().endswith(IMG_EXT) else 'model'} if key == 'garments': row.update(manifest_for(p, f)) rows.append(row) out[key] = rows out['outfits'] = sorted(f[:-5] for f in os.listdir(OUTFITS) if f.endswith('.json')) return out # Canonical slots + draw order. The layer int drives BOTH the 2D doll stack and the 3D # normal-push, so one garment's ordering is the same in either product. SLOTS = ['head', 'torso', 'outer', 'legs', 'feet', 'hand', 'accessory', 'bag'] LAYER = {'skin': 0, 'legs': 20, 'feet': 25, 'torso': 30, 'outer': 40, 'head': 50, 'hand': 55, 'accessory': 60, 'bag': 70} # Guessing a slot from the filename is a convenience, never an assertion — every guess is # overridable and the manifest records which ones were inferred rather than set. SLOT_HINTS = [ ('head', r'hat|cap|beanie|helmet|hood|bucket|fedora|akubra|visor|glasses|sunnies'), ('feet', r'shoe|boot|sneaker|trainer|sandal|thong|volley|plimsoll|ugg|blundstone'), ('legs', r'pant|trouser|jean|short|skirt|trackie|jogger|legging|stubbies|cargo'), ('outer', r'jacket|coat|parka|windbreaker|hoodie|anorak|blazer|cardigan'), ('torso', r'shirt|tee|top|jumper|sweater|knit|singlet|polo|blouse|flanno|flannel'), ('bag', r'bag|backpack|rucksack|satchel|bumbag'), ('hand', r'glove|mitt|watch|bracelet'), ] def guess_slot(name): n = name.lower() for slot, pat in SLOT_HINTS: if re.search(pat, n): return slot return None def load_manifest(): if os.path.exists(MANIFEST): try: return json.load(open(MANIFEST)) except ValueError: pass return {'schemaVersion': 1, 'items': {}} def save_manifest(m): tmp = MANIFEST + '.tmp' with open(tmp, 'w') as f: json.dump(m, f, indent=1, sort_keys=True) os.replace(tmp, MANIFEST) # atomic — a half-written index would blank the wardrobe def manifest_for(path, name): """The record for one asset: stored fields win, the rest are inferred and marked as such.""" m = load_manifest() rec = dict(m['items'].get(os.path.basename(path), {})) if 'slot' not in rec: g = guess_slot(name) if g: rec['slot'], rec['slot_inferred'] = g, True rec.setdefault('layer', LAYER.get(rec.get('slot'), 30)) rec.setdefault('tags', []) rec['nsfw'] = is_nsfw(name) return rec 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.startswith('/vendor/'): # vendored three.js — no CDN, works offline f = os.path.realpath(os.path.join(ROOT, 'web', u.path.lstrip('/'))) web = os.path.realpath(os.path.join(ROOT, 'web')) if not f.startswith(web + os.sep) or not os.path.isfile(f): return self.j({'error': 'not found'}, 404) ctype = 'text/javascript' if f.endswith('.js') else (mimetypes.guess_type(f)[0] or 'application/octet-stream') return self.send(200, open(f, 'rb').read(), ctype) 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 == '/thumb': # lazy 256px thumb, cached; the grid needs these p = q.get('p', '') if not allowed(p) or not os.path.isfile(p): return self.j({'error': 'nope'}, 403) key = hashlib.sha1(os.path.realpath(p).encode()).hexdigest()[:12] tgt = os.path.join(THUMBS, key + '.png') if not os.path.exists(tgt) or os.path.getmtime(tgt) < os.path.getmtime(p): try: if p.lower().endswith(IMG_EXT): from PIL import Image # 2D layers: a downscale, not a render im = Image.open(p).convert('RGBA') im.thumbnail((256, 256), Image.LANCZOS) im.save(tgt) else: run_blender(['thumb', glb_of(p), tgt, 256]) except Exception as e: return self.j({'error': str(e)[-200:]}, 500) return self.send(200, open(tgt, 'rb').read(), 'image/png') 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/rig': # bench step 1: raw mesh → rigged mixamorig # MIRPAMO is the local offline Mixamo (auto-rig + retarget). Its skeleton is 22 bones # with NO fingers, which is fine for locomotion but leaves cuff/forearm verts # under-driven if you later hang a 65-bone donor garment on it — say so rather than # let it surprise you downstream. p = body.get('path', '') if not allowed(p) or not os.path.isfile(p): return self.j({'error': 'path outside library'}, 403) if not os.path.exists(MIRPAMO): return self.j({'error': 'MIRPAMO not found at ' + MIRPAMO}, 400) jid = new_job('rig', 'rig ' + os.path.basename(p)) def fx(jid): out = os.path.join(DIRS['bodies'], tagged_name(slug(os.path.basename(p).rsplit('.', 1)[0]) + '-rigged.glb', is_nsfw(p))) cmd = [MIRPAMO, 'rig', p, '-o', out] if body.get('tris'): cmd += ['--tris', str(int(body['tris']))] r = subprocess.run(cmd, capture_output=True, text=True, timeout=3600) JOBS[jid]['log'] = '\n'.join((r.stdout + r.stderr).strip().splitlines()[-12:]) if r.returncode != 0 or not os.path.exists(out): raise RuntimeError(JOBS[jid]['log'][-400:] or 'rig failed') return out job_thread(jid, fx) return self.j({'job': jid}) if u.path == '/api/meta': # set slot / layer / tags on a garment p = body.get('path', '') if not allowed(p) or not os.path.isfile(p): return self.j({'error': 'path outside library'}, 403) m = load_manifest() rec = m['items'].setdefault(os.path.basename(p), {}) if body.get('slot') in SLOTS: rec['slot'] = body['slot'] rec['layer'] = LAYER.get(body['slot'], 30) rec.pop('slot_inferred', None) # an explicit choice is no longer a guess if isinstance(body.get('tags'), list): rec['tags'] = [str(t).strip().lower() for t in body['tags'] if str(t).strip()] if body.get('title'): rec['title'] = str(body['title'])[:80] save_manifest(m) return self.j({'ok': True, 'rec': rec}) if u.path == '/api/tag': # add/remove the -nsfw suffix on an asset p, nsfw = body.get('path', ''), bool(body.get('nsfw')) if not allowed(p) or not os.path.isfile(p): return self.j({'error': 'path outside library'}, 403) d, name = os.path.split(p) new = tagged_name(name, nsfw) if new == name: return self.j({'ok': True, 'path': p, 'note': 'already tagged that way'}) tgt = os.path.join(d, new) if os.path.exists(tgt): return self.j({'error': 'a file with that name already exists'}, 409) os.rename(p, tgt) # the convert cache is keyed on the source path, so a rename orphans its entry return self.j({'ok': True, 'path': tgt, 'nsfw': nsfw}) 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 _i_.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) # The house rule "bases stay in the library, only dressed outfits ship into games" # has lived as one unenforced line in the README. This is the enforcement. nsfw = [s for s in stems if is_nsfw(s)] if nsfw: return self.j({'error': f'refusing to export nsfw-tagged asset into a game ' f'directory: {nsfw[0]}'}, 403) 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']] # An assembled result INHERITS the body's nsfw tag. Clearing it automatically # because "a garment was added" would be a silent wrong call — a hat does not # clothe a nude base — and the failure mode is shipping a nude model into a # game. Untagging stays a deliberate act once you've looked at the result. out = os.path.join(DIRS['out'], tagged_name(slug(a.get('name') or 'outfit') + '.glb', is_nsfw(a['body']))) run_blender(['assemble', b, out] + gs, jid) elif op == 'unitfix': src = glb_of(a['path'], jid) base = slug(os.path.basename(a['path']).rsplit('.', 1)[0]) out = os.path.join(DIRS['bodies'], tagged_name(base + '-m.glb', is_nsfw(a['path']))) run_blender(['unitfix', src, out, a.get('height', 1.72)], 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()