Phase 0 — the fixes that were blocking every tier:
· /api/blender had no allowed() guard, unlike every other path-taking endpoint. Under the
documented WG_HOST=0.0.0.0 that was an unauthenticated arbitrary-file -> Blender import +
library-write for any tailnet peer. Now gated on allowed() + isfile.
· scan() listed garments as models only, but /api/hangable writes .png there — so every
cut-out the generator produced was invisible in its own UI and the whole 2D paper-doll
route dead-ended. garments now lists models AND images.
· mb_download() wrote json.dumps(data) whenever the farm response wasn't bytes, and mb_req
parses any JSON-200 into a dict — so a farm error body got written *into* the target
.png/.glb and the job was still marked done. Now raises instead.
· MB_TOKEN is read off disk from backnforth/.env, so the farm tiers (RMBG-2.0, image-edit
try-on, trellis) stop silently dying when a launch forgets to export it.
· glb_of() keyed its cache on the basename alone, so two sources that slug the same served
each other's geometry; the key now carries a hash of the full path.
· JOBS grew forever and glb_of minted an orphan job on every cache miss inside GET /glb.
run_blender() now tolerates jid=None and finished jobs get reaped.
Phase 1 — a dressed, animated character on screen:
· clip transport: the mixer used to blind-play animations[0] with no way to reach the rest.
Clip dropdown + play/pause + scrub + speed, with the scrub head following playback.
· rigid attach stopped being a lie. It cloned into three.js and POSTed nothing ("preview
only"), so every hat placement died on reload. Attachments now carry their bone/offset/
tint and persist via /api/outfit -> library/outfits/<name>.json, reloadable from a chip.
· colourway swatches retint the last attached item (materials are cloned per instance so a
tint doesn't bleed), giving free variants with no re-generation.
· flat cut-out garments get a 2D preview pane — the paper-doll tier had no way to be seen.
Verified end-to-end in the browser: tradie_at_wanwalk (22-bone mixamorig) walks wearing a
red-tinted felt hat on mixamorigHead, saved, page reloaded, restored from disk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
454 lines
22 KiB
Python
454 lines
22 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, 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()
|