wardrobegod/server.py
type-two 7c5fc53224 Registry writer: make the four non-file-drop consumers one-click, and keep 3GOD as a depot
Most GODVERSE consumers do not scan a directory — they load from a literal array in source
(djsim/procity PED_NAMES, thriftgod HERO_FLOOR) or a JSON manifest, so a dropped file does
nothing until its NAME is in that list. tools/registry.py makes that edit, and the export hub
now offers it instead of only reporting the file:line.

Paranoid by construction, because the targets are live shipping games:
· dry-run by default; write:true is required to touch source
· idempotent — an existing entry is a no-op, verified against a real djsim ped
· backs up to <file>.bak-<stamp> before every write
· refuses if the anchor matches anything other than exactly once, rather than guessing which
  of several arrays was meant
· handles djsim keeping TWO live copies of PED_NAMES (rigs.js owns the street, index.html has
  a second) — updating one and not the other is a silent half-fix
· works locally and over ssh, since djsim lives on ultra

Bug I introduced and caught while testing: the first implementation inserted after the opening
bracket, i.e. PREPENDED. procity's loadPedFleet fills fixed slots BY PED_NAMES INDEX, so that
silently reassigns every existing character — and my own note on that target said "appending is
safe, reordering is not" three lines above the code that did it. Now scans to the matching
close bracket (string-aware) and appends. Verified: leading entries byte-identical, new name at
the tail, then reverted from backup with git clean.

Also corrected procity's path — its rigs.js is under web/js/citizens/, not crowd/, and declares
`export const`; the two games diverged despite sharing the loadPedFleet shape.

On 3GOD: NOT retired. It looked like a spent planning experiment, but thriftgod fetches its
props from it at runtime in production (index.html:1176 DEPOT = 'https://digalot.fyi/3god') and
procity does the same — 773 live assets. It is two things bolted together: a dumb asset CDN
(/a, /dl, /t, /api/list) which stays, and an authoring UI (upload/tag/thumb/fetch/meta) which
wardrobegod now supersedes. Absorb the bench, keep the depot, publish into it.

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

1119 lines
59 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
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'))
# The 3GOD depot MUST be addressed direct over the tailnet. Its auth trusts the raw socket peer
# against an allow-list, so through the digalot.fyi Cloudflare front the peer is CF and every
# write 403s — measured: /api/list reports authed=false via CF, authed=true direct. (The shipped
# prop_campaign.py --publish defaults to the CF URL and is broken for this exact reason.)
GOD3 = os.environ.get('WG_GOD3', 'http://100.94.195.115:8788')
# Verified against each consumer's actual loader code, not its docs. "ready" means a file drop
# alone makes the asset appear; everything else needs a registry edit first, and exporting
# without one produces a file nobody loads.
EXPORT_TARGETS = {
'djsim-doll': {'ready': True,
'why': 'djsim resolves doll art by filename and self-heals on restart'},
'3god': {'ready': True,
'why': 'HTTP depot; thriftgod and procity both read their props from it'},
'djsim-ped': {'ready': False,
'why': 'peds load from a hardcoded array, not a directory scan',
'registry': 'ultra 90sDJsim/web/world/crowd/rigs.js PED_NAMES (:20-27) — the '
'array that owns the street; index.html:639 has a second copy'},
'procity-ped': {'ready': False,
'why': 'same pattern — loadPedFleet reads a fixed name list',
'registry': 'PROCITY/web/models/peds/ + the ped name array; publish props via '
'pipeline/publish.py on m3ultra (the only box with _normalized/)'},
'thriftgod-prop': {'ready': False,
'why': 'thriftgod has NO garment consumer; props are hardcoded hero lists',
'registry': 'thriftgod/web/index.html HERO_FLOOR / HERO_COUNTER (:1322-1325) '
'— the filename must match a literal string exactly'},
'not-tonight': {'ready': False,
'why': 'only ingests public/props/<name>.png and requires a manifest entry; '
'there is no doll, garment or character art path at all',
'registry': 'not-tonight/public/props/manifest.json (+ tools/props_import.py)'},
}
def god3_name(name):
"""Reproduce the depot's clean_name: illegal characters are DELETED, not substituted.
That deletion is the hazard — 'shop!-cat.glb' cleans to 'shop-cat.glb' and silently serves an
existing, different mesh. Case is significant and spaces/underscores survive verbatim.
"""
n = re.sub(r'[^A-Za-z0-9._ -]', '', os.path.basename(name))
n = re.sub(r'^[^A-Za-z0-9]+', '', n)
return n or 'asset.glb'
# 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')
try: # registry edits into consumers that load from hardcoded lists
import registry as REG
except Exception as _e:
REG = None
print(f'registry edits off ({_e.__class__.__name__}: {_e})')
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))
elif key == 'bodies':
rec = load_manifest()['items'].get(f, {})
row['body_type'] = rec.get('body_type') or DEFAULT_BODY_TYPE
row['height'] = body_height(rec)
row['tags'] = rec.get('tags', [])
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
# Body types carry their own target height. This is the whole reason unitfix refuses to
# normalise everything to one number: a 'small' and an 'obese' character are not the same height,
# and flattening them would erase the range the library exists to hold. Export applies the height
# for the body's declared type; unitfix only ever repairs physically impossible scales.
BODY_TYPES = {
'small': 1.58, 'medium': 1.72, 'large': 1.88, 'tall': 1.95,
'child': 1.30, 'obese': 1.74, 'stocky': 1.68,
}
DEFAULT_BODY_TYPE = 'medium'
def body_height(rec):
t = (rec or {}).get('body_type') or DEFAULT_BODY_TYPE
return (rec or {}).get('height') or BODY_TYPES.get(t, BODY_TYPES[DEFAULT_BODY_TYPE])
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 thumb_img(src, tgt):
"""Thumbnail a 2D asset — cropped to its content first.
Doll layers are staged on a 704x1408 canvas with the garment occupying a slot-sized patch of
it, so a plain downscale renders a mostly-empty tile with a tiny garment in it. Cropping to
the alpha bbox makes a jumper look like a jumper at 256px.
"""
from PIL import Image
im = Image.open(src).convert('RGBA')
bb = im.getbbox() # None on a fully transparent image
if bb:
im = im.crop(bb)
im.thumbnail((256, 256), Image.LANCZOS)
im.save(tgt)
def doll_catalogue():
"""The 2D layers, enriched with the same manifest records the 3D garments use.
The two catalogues were separate, so tag search only covered 3D and the 392 doll layers were
invisible to it. They share one manifest now; the doll's slot comes from its filename prefix
(which is authoritative — djsim resolves art by it), so it is never a guess here.
"""
cat = DOLL.catalogue() if DOLL else {}
m = load_manifest()
for slot, items in cat.items():
for it in items:
rec = dict(m['items'].get(os.path.basename(it['path']), {}))
it['slot'] = DOLL_SLOT_MAP.get(slot, slot) # doll slot names -> canonical slots
it['layer'] = LAYER.get(it['slot'], 30)
it['tags'] = rec.get('tags', [])
it['title'] = rec.get('title') or it['key'].replace('_', ' ')
it['nsfw'] = is_nsfw(it['path'])
it['tier'] = '2d'
return cat
# djsim's doll uses top/bottom/shoes/hat/bag; the library's canonical slots are broader.
DOLL_SLOT_MAP = {'top': 'torso', 'bottom': 'legs', 'shoes': 'feet', 'hat': 'head', 'bag': 'bag'}
CANON_TO_DOLL = {v: k for k, v in DOLL_SLOT_MAP.items()}
# ---------------------------------------------------------------------------------------
# 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):
thumb_img(p, 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/thumbs/warm': # pre-render missing thumbs so the grid isn't cold
jid = new_job('thumbs', 'warming thumbnails')
def fx(jid):
lib = scan()
todo = []
for key in ('garments', 'bodies', 'out'):
for it in lib.get(key, []):
p = it['path']
k = hashlib.sha1(os.path.realpath(p).encode()).hexdigest()[:12]
t = os.path.join(THUMBS, k + '.png')
if not os.path.exists(t) or os.path.getmtime(t) < os.path.getmtime(p):
todo.append((p, t))
for d in (doll_catalogue() or {}).values():
for it in d:
p = it['path']
k = hashlib.sha1(os.path.realpath(p).encode()).hexdigest()[:12]
t = os.path.join(THUMBS, k + '.png')
if not os.path.exists(t):
todo.append((p, t))
done = fail = 0
# Deliberately SERIAL: each 3D thumb launches its own Blender, and firing a
# grid's worth at once buries the machine (and every request blocks a server
# thread). Slow and quiet beats fast and unusable.
for i, (p, t) in enumerate(todo):
JOBS[jid]['note'] = f'{i + 1}/{len(todo)} {os.path.basename(p)[:28]}'
try:
if p.lower().endswith(IMG_EXT):
thumb_img(p, t)
else:
run_blender(['thumb', glb_of(p), t, 256])
done += 1
except Exception:
fail += 1 # a single unreadable asset must not abort the sweep
return f'{done} rendered, {fail} failed, {len(todo)} queued'
job_thread(jid, fx)
return self.j({'job': jid})
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]
if body.get('body_type') in BODY_TYPES:
rec['body_type'] = body['body_type']
rec.pop('height', None) # type drives height unless overridden below
if body.get('height'):
try:
rec['height'] = max(0.3, min(3.0, float(body['height'])))
except (TypeError, ValueError):
pass
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/export': # the hub: one door, honest about each target
tgt, p = body.get('target', ''), body.get('path', '')
if tgt not in EXPORT_TARGETS:
return self.j({'error': 'target must be one of ' + '|'.join(EXPORT_TARGETS)}, 400)
t = EXPORT_TARGETS[tgt]
if not t['ready']:
# Verified against each consumer's real loader: these ingest by a HARDCODED array
# or a manifest, so dropping a file does nothing until its NAME is in that list.
# We can make that edit — but it is source surgery on a live game, so it dry-runs
# unless explicitly told to write.
if not REG:
return self.j({'ok': False, 'ready': False, 'target': tgt, 'why': t['why'],
'registry': t.get('registry')}, 409)
nm = body.get('name') or os.path.basename(p).rsplit('.', 1)[0]
try:
res = REG.add(tgt, nm, do_write=bool(body.get('write')))
except Exception as e:
return self.j({'error': str(e)[:300]}, 500)
return self.j({'ok': True, 'ready': False, 'registry_edit': res,
'why': t['why'],
'note': ('written — back up files are alongside each source'
if body.get('write') else
'DRY RUN — re-send with write:true to actually edit the source')})
if not allowed(p) or not os.path.isfile(p):
return self.j({'error': 'path outside library'}, 403)
if is_nsfw(p):
return self.j({'error': 'refusing to publish an nsfw-tagged asset'}, 403)
if tgt == '3god':
name = god3_name(body.get('name') or os.path.basename(p))
if not name.lower().endswith('.glb'):
return self.j({'error': '3GOD wants a .glb'}, 400)
jid = new_job('export', f'3god ← {name}')
def fx(jid):
# The depot DELETES illegal characters rather than substituting, so two
# different sources can clean to one name and the second silently serves the
# first's mesh. Check before writing.
try:
listing = json.load(urllib.request.urlopen(GOD3 + '/api/list', timeout=30))
have = {a.get('file') or a.get('name') for a in
(listing.get('assets') or listing.get('items') or [])}
except Exception:
have = set()
if name in have and not body.get('overwrite'):
raise RuntimeError(f'{name} already exists in the depot — pass overwrite:true '
f'if you really mean to replace it')
data = open(p, 'rb').read()
req = urllib.request.Request(
GOD3 + '/api/upload?name=' + urllib.parse.quote(name), data=data,
headers={'Content-Type': 'model/gltf-binary'}, method='POST')
with urllib.request.urlopen(req, timeout=600) as r:
out = r.read()[:200]
JOBS[jid]['log'] = out.decode('utf-8', 'replace')
return GOD3 + '/a/' + name
job_thread(jid, fx)
return self.j({'job': jid, 'name': name, 'depot': GOD3})
return self.j({'error': 'unhandled target'}, 500)
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)
# 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()