diff --git a/server.py b/server.py index bc22d1b..b611c09 100644 --- a/server.py +++ b/server.py @@ -35,6 +35,48 @@ BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS 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/.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 @@ -127,6 +169,11 @@ def scan(): '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')) @@ -159,6 +206,22 @@ def guess_slot(name): 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: @@ -189,8 +252,46 @@ def manifest_for(path, 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(): - return DOLL.catalogue() if DOLL else {} + """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()} # --------------------------------------------------------------------------------------- @@ -421,10 +522,7 @@ class H(BaseHTTPRequestHandler): 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) + thumb_img(p, tgt) else: run_blender(['thumb', glb_of(p), tgt, 256]) except Exception as e: @@ -458,6 +556,44 @@ class H(BaseHTTPRequestHandler): 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 @@ -499,6 +635,14 @@ class H(BaseHTTPRequestHandler): 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}) @@ -655,6 +799,53 @@ class H(BaseHTTPRequestHandler): 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 at all. Saying so beats a + # green tick over a no-op. + return self.j({'ok': False, 'ready': False, 'target': tgt, + 'why': t['why'], 'registry': t.get('registry'), + 'note': 'not a file drop — the consumer needs a registry edit first'}, 409) + 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) diff --git a/web/index.html b/web/index.html index 86d8298..44f87ce 100644 --- a/web/index.html +++ b/web/index.html @@ -99,6 +99,11 @@
fix units only corrects impossible heights (<0.5m or >3m) — real small/medium/large proportions are kept. MIRPAMO rigs to 22 bones, no fingers: fine for locomotion, but a 65-bone donor garment will leave cuffs under-driven.
+
+ +
+
export uses the type's height, so small/large/obese keep their + real proportions instead of all being forced to one number.

LOD

@@ -174,6 +179,7 @@
+

rigid attach (preview)

@@ -232,7 +238,14 @@ @@ -451,21 +464,35 @@ function visible(rows) { // ---------- dressing-room grid ---------- const SLOTS = ['head', 'torso', 'outer', 'legs', 'feet', 'hand', 'accessory', 'bag']; -let SLOTFILTER = '', GARMQ = ''; +let SLOTFILTER = '', GARMQ = '', TIER = 'all'; + +// One wardrobe, two tiers. The 392 doll layers used to live in a separate catalogue that search +// couldn't reach, so "find the coogi jumper" only ever looked at the 3D garments. +function allGarments() { + const three = (LIB.garments || []).map(g => ({ ...g, tier: g.tier || '3d' })); + const two = Object.values(LIB.doll || {}).flat(); + return TIER === '3d' ? three : TIER === '2d' ? two : three.concat(two); +} function renderGarmentGrid() { - const all = visible(LIB.garments); + const all = visible(allGarments()); const counts = {}; all.forEach(g => { if (g.slot) counts[g.slot] = (counts[g.slot] || 0) + 1; }); const unset = all.filter(g => !g.slot).length; $('slotTabs').innerHTML = + ['all', '3d', '2d'].map(t => + ``).join('') + + '' + `` + SLOTS.filter(s => counts[s]).map(s => ``).join('') + (unset ? `` : ''); - document.querySelectorAll('#slotTabs button').forEach(b => b.onclick = () => { + document.querySelectorAll('#slotTabs button[data-slot]').forEach(b => b.onclick = () => { SLOTFILTER = b.dataset.slot; renderGarmentGrid(); }); + document.querySelectorAll('#slotTabs button[data-tier]').forEach(b => b.onclick = () => { + TIER = b.dataset.tier; renderGarmentGrid(); + }); const q = GARMQ.trim().toLowerCase(); const rows = all.filter(g => { @@ -486,8 +513,13 @@ function renderGarmentGrid() { }).join('') || '
nothing matches — clear the filter or generate one →
'; document.querySelectorAll('#garments .card').forEach(el => el.onclick = () => { - const g = (LIB.garments || []).find(x => x.path === el.dataset.p); - if (g) { previewGarment({ name: g.name, path: g.path }); showMeta(g); } + const g = allGarments().find(x => x.path === el.dataset.p); + if (!g) return; + if (g.tier === '2d') { // a doll layer wears itself on the doll, not the 3D stage + DOLLPICK[CANON_TO_DOLL[g.slot] || g.slot] = g.stem; + toast('doll: wearing ' + (g.title || g.key)); + composeDoll(); showMeta(g); + } else { previewGarment({ name: g.name, path: g.path }); showMeta(g); } }); } @@ -498,6 +530,47 @@ function showMeta(g) { $('metaNote').textContent = g.slot_inferred ? 'slot guessed from the filename — confirm it' : ''; } $('garmSearch').oninput = e => { GARMQ = e.target.value; renderGarmentGrid(); }; +// export hub — targets and their readiness come from the server, which learned them by reading +// each consumer's actual loader +const EXPORT_TARGETS = [ + ['3god', '3GOD depot (thriftgod + procity read it)', true], + ['djsim-doll', '90sDJsim paper doll', true], + ['djsim-ped', '90sDJsim ped — needs registry', false], + ['procity-ped', 'PROCITY ped — needs registry', false], + ['thriftgod-prop', 'thriftgod prop — needs registry', false], + ['not-tonight', 'not-tonight — no garment path', false], +]; +$('expTarget').innerHTML = EXPORT_TARGETS.map(([v, l, ok]) => + ``).join(''); +$('expBtn').onclick = async () => { + const it = GARM || BODY; + if (!it) return toast('select a garment or body first'); + const target = $('expTarget').value; + if (target === 'djsim-doll') return $('dollExport').click(); + const r = await fetch('/api/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target, path: it.path, overwrite: $('expOver').checked }) }).then(r => r.json()); + if (r.ready === false) { + $('expNote').innerHTML = `not a file drop. ${r.why}
edit: ${r.registry || '?'}`; + return toast('⚠ ' + target + ' needs a registry edit'); + } + if (r.error) { $('expNote').textContent = r.error; return toast('✗ ' + r.error); } + $('expNote').textContent = `publishing as ${r.name} → ${r.depot}`; + const out = await pollJob(r.job, 'export'); + if (out) $('expNote').textContent = 'published: ' + out; +}; +async function pollJob(job, label) { + while (true) { + await new Promise(r => setTimeout(r, 1500)); + const j = await fetch('/api/job/' + job).then(r => r.json()); + toast(`${label}: ${j.status}${j.note ? ' · ' + j.note : ''}`); + if (j.status === 'done') { refresh(); return j.out; } + if (j.status === 'error') { toast('✗ ' + (j.log || '').split('\n').pop()); return null; } + } +} +$('warmBtn').onclick = async () => { + const out = await runJob('/api/thumbs/warm', {}, 'thumbs'); + if (out !== null) renderGarmentGrid(); +}; $('metaSave').onclick = async () => { if (!GARM) return toast('pick a garment first'); const r = await fetch('/api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -543,6 +616,19 @@ function wireUpload(inputId, to) { wireUpload('upBody', 'bodies'); wireUpload('upGarm', 'garments'); $('showNsfw').onchange = e => { SHOW_NSFW = e.target.checked; renderLists(); }; + +// body types carry their own target height — see BODY_TYPES in server.py +const BODY_TYPES = { small: 1.58, medium: 1.72, large: 1.88, tall: 1.95, child: 1.30, obese: 1.74, stocky: 1.68 }; +$('bodyType').innerHTML = '' + + Object.entries(BODY_TYPES).map(([t, h]) => ``).join(''); +$('bodyTypeSave').onclick = async () => { + if (!BODY) return toast('pick a body first'); + const t = $('bodyType').value; if (!t) return toast('choose a type'); + const r = await fetch('/api/meta', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: BODY.path, body_type: t }) }).then(r => r.json()); + toast(r.error ? '✗ ' + r.error : `✓ ${BODY.name} is ${t} (${BODY_TYPES[t]}m on export)`); + refresh(); +}; $('tagBtn').onclick = async () => { const it = BODY || GARM; if (!it) return toast('select a body or garment first'); @@ -663,6 +749,7 @@ $('costumeSel').onchange = e => { // in the game with no code change. Composition happens server-side (PIL) so the DOM preview // and the exported PNG are guaranteed to be the same image. const DOLL_SLOTS = ['hat', 'top', 'bottom', 'shoes', 'bag']; +const CANON_TO_DOLL = { torso: 'top', legs: 'bottom', feet: 'shoes', head: 'hat', bag: 'bag' }; let DOLLPICK = {}; function renderDoll() {