/api/gen prefers Cloudflare flux-1-schnell (free ~10k neurons/day) over the farm; /api/rmbg falls back to a Pillow border-flood white-keyer when no MB_TOKEN (flood from edges through near-white only — traps-ledger rule, white ON the garment survives). Creds in gitignored .env, lifted from ultra. Verified end-to-end: brown-corduroy-jacket flux'd, keyed, in the wardrobe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
317 lines
19 KiB
HTML
317 lines
19 KiB
HTML
<!doctype html>
|
|
<meta charset="utf-8">
|
|
<title>WARDROBEGOD — one skeleton, infinite fits</title>
|
|
<style>
|
|
:root { --bg:#14120e; --panel:#1c1913; --line:#37311f; --gold:#e8c257; --ink:#d8d2c2; --dim:#8a8270; }
|
|
* { box-sizing:border-box } html,body { margin:0; height:100%; background:var(--bg); color:var(--ink);
|
|
font:14px/1.45 -apple-system, system-ui, sans-serif }
|
|
#app { display:grid; grid-template-columns:270px 1fr 340px; grid-template-rows:46px 1fr 26px; height:100% }
|
|
header { grid-column:1/4; display:flex; align-items:center; gap:14px; padding:0 14px; border-bottom:1px solid var(--line) }
|
|
header h1 { font-size:17px; letter-spacing:2px; color:var(--gold); margin:0 } header .sub{color:var(--dim);font-size:12px}
|
|
header .right { margin-left:auto; display:flex; gap:8px; align-items:center }
|
|
.col { border-right:1px solid var(--line); overflow:auto; padding:10px }
|
|
.col:last-child { border-right:none; border-left:1px solid var(--line) }
|
|
h2 { font-size:11px; letter-spacing:2px; color:var(--gold); margin:12px 0 6px; text-transform:uppercase }
|
|
.item { padding:6px 8px; border:1px solid var(--line); border-radius:8px; margin:4px 0; cursor:pointer;
|
|
display:flex; justify-content:space-between; gap:6px; background:var(--panel) }
|
|
.item:hover { border-color:var(--gold) } .item.on { border-color:var(--gold); background:#26210f }
|
|
.item .meta { color:var(--dim); font-size:11px; white-space:nowrap }
|
|
.item .nm { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
|
#stage { position:relative; min-width:0; overflow:hidden } canvas { display:block; max-width:100% }
|
|
#app > .col { min-width:0 }
|
|
@media (max-width: 1100px) { #app { grid-template-columns:220px 1fr 300px } }
|
|
#hud { position:absolute; left:10px; top:10px; background:#0009; padding:6px 10px; border-radius:8px; font-size:12px; color:var(--ink); max-width:60% }
|
|
#foot { grid-column:1/4; display:flex; align-items:center; gap:14px; padding:0 12px; border-top:1px solid var(--line); font-size:12px; color:var(--dim); overflow:hidden; white-space:nowrap }
|
|
button { background:var(--gold); color:#211a04; border:none; border-radius:8px; padding:6px 10px; font-weight:700; cursor:pointer; font-size:12px }
|
|
button.ghost { background:none; color:var(--ink); border:1px solid var(--line) }
|
|
button:disabled { opacity:.4; cursor:default }
|
|
input, select, textarea { background:#12100b; color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:6px 8px; font:13px system-ui; width:100% }
|
|
.row { display:flex; gap:6px; margin:5px 0; align-items:center } .row label { width:74px; color:var(--dim); font-size:12px }
|
|
.tabs { display:flex; gap:4px; margin-bottom:8px } .tabs button { background:none; border:1px solid var(--line); color:var(--dim) }
|
|
.tabs button.on { border-color:var(--gold); color:var(--gold) }
|
|
#genPrev { width:100%; border-radius:8px; border:1px solid var(--line); display:none }
|
|
.note { color:var(--dim); font-size:12px; margin:6px 0 }
|
|
#how { position:absolute; right:12px; top:12px; width:330px; background:#0e0c08ee; border:1px solid var(--line);
|
|
border-radius:10px; padding:12px; display:none; z-index:5; font-size:12.5px }
|
|
#how b { color:var(--gold) } #how ol { margin:6px 0 6px 18px; padding:0 }
|
|
</style>
|
|
<div id="app">
|
|
<header>
|
|
<h1>WARDROBEGOD</h1><span class="sub">one skeleton · infinite fits</span>
|
|
<span class="right">
|
|
<span id="farm" class="sub"></span>
|
|
<button class="ghost" id="howBtn">how does clothing work?</button>
|
|
</span>
|
|
</header>
|
|
|
|
<div class="col" id="left">
|
|
<h2>bodies</h2>
|
|
<input type="file" id="upBody" accept=".glb,.gltf,.fbx,.obj" style="margin-bottom:6px">
|
|
<div id="bodies"></div>
|
|
<h2>body ops</h2>
|
|
<div class="row"><label>height m</label><input id="scaleH" value="1.72"><button id="scaleBtn" class="ghost">scale</button></div>
|
|
<div class="row"><label>target tris</label><input id="decT" value="18000"><button id="decBtn" class="ghost">decimate</button></div>
|
|
<div class="note">decimate keeps skin weights — the safe local path for rigged meshes (never the farm /finish).</div>
|
|
</div>
|
|
|
|
<div id="stage">
|
|
<div id="hud">pick a body…</div>
|
|
<div id="how">
|
|
<b>How clothing works here (3 tiers)</b>
|
|
<ol>
|
|
<li><b>Rigid</b> — hats/bags/shoes: attach to a bone (picker below right). No deformation needed.</li>
|
|
<li><b>Deforming</b> — shirts/pants: <b>FIT</b> copies the body's skin weights onto the garment
|
|
(Blender, nearest-face) so one skeleton drives body + clothes. Garment becomes a wardrobe
|
|
item that fits every animation.</li>
|
|
<li><b>Swap</b> — socket torsos (character_kit_modular): a "shirt" that replaces the torso. Zero clipping.</li>
|
|
</ol>
|
|
<b>Generating NEW clothes ($0, MODELBEAST)</b>
|
|
<ol>
|
|
<li><b>flux</b> — product-shot of the garment, flat, white bg</li>
|
|
<li><b>cutout</b> — bg_remove</li>
|
|
<li>then EITHER <b>→ rigid 3D</b> (trellis, ~5 min — hats/shoes/bags only; thin cloth dies in
|
|
reconstruction) OR <b>→ hanging texture</b> (rack planes / paper-doll — flat clothes LOVE this)</li>
|
|
</ol>
|
|
<div class="note">Deforming clothes are authored/retopo'd against the base body then FIT — never raw-genned.
|
|
Bases stay in the library; only dressed outfits ship into games.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col" id="right">
|
|
<div class="tabs">
|
|
<button class="on" data-tab="wardrobe">wardrobe</button>
|
|
<button data-tab="generate">generate</button>
|
|
<button data-tab="out">outfits</button>
|
|
</div>
|
|
|
|
<div id="tab-wardrobe">
|
|
<h2>garments</h2>
|
|
<input type="file" id="upGarm" accept=".glb,.gltf,.fbx,.obj,.png,.jpg" style="margin-bottom:6px">
|
|
<div id="garments"></div>
|
|
<h2>rigid attach (preview)</h2>
|
|
<div class="row"><label>bone</label><select id="boneSel"><option>—</option></select></div>
|
|
<div class="row"><label>scale</label><input id="atScale" type="range" min="0.05" max="2" step="0.01" value="0.25"></div>
|
|
<div class="row"><label>up/down</label><input id="atY" type="range" min="-0.5" max="0.5" step="0.005" value="0.08"></div>
|
|
<div class="row"><label>fwd/back</label><input id="atZ" type="range" min="-0.5" max="0.5" step="0.005" value="0"></div>
|
|
<div class="row"><button id="attachBtn" class="ghost">attach selected</button><button id="clearAtBtn" class="ghost">clear</button></div>
|
|
<h2>deforming fit (blender)</h2>
|
|
<div class="row"><label>inflate mm</label><input id="fitInf" value="3"></div>
|
|
<div class="row">
|
|
<button id="fitBtn">FIT → wardrobe item</button>
|
|
<button id="fitMergeBtn" class="ghost">fit → dressed glb</button>
|
|
</div>
|
|
<div class="note">FIT needs a <b>rigged</b> body (armature + weights). Unrigged base? Rig it first (MIRPAMO / Mixamo).</div>
|
|
<h2>assemble outfit</h2>
|
|
<div id="fitList" class="note">fitted items you tick in the garments list join the outfit…</div>
|
|
<div class="row"><input id="outfitName" placeholder="outfit name e.g. trackie-bloke"><button id="asmBtn">ASSEMBLE</button></div>
|
|
</div>
|
|
|
|
<div id="tab-generate" style="display:none">
|
|
<h2>new garment — step 1: image</h2>
|
|
<textarea id="genPrompt" rows="3" placeholder="e.g. brown corduroy jacket / bucket hat / white leather sneakers"></textarea>
|
|
<div class="row"><button id="genBtn">flux it</button><span class="note" id="genNote"></span></div>
|
|
<img id="genPrev">
|
|
<h2>step 2: cutout</h2>
|
|
<div class="row"><button id="rmbgBtn" class="ghost" disabled>bg_remove</button></div>
|
|
<h2>step 3: make it an item</h2>
|
|
<div class="row"><button id="to3dBtn" class="ghost" disabled>→ rigid 3D (hats/shoes/bags)</button></div>
|
|
<div class="row"><button id="hangBtn" class="ghost" disabled>→ hanging texture (racks/doll)</button></div>
|
|
<div class="note">rigid = trellis on the farm (~5 min). Thin/floppy cloth should go the texture route or be
|
|
modelled against the base and FIT instead — reconstruction eats thin geometry (traps ledger).</div>
|
|
<h2>generated</h2><div id="genLib"></div>
|
|
</div>
|
|
|
|
<div id="tab-out" style="display:none">
|
|
<h2>finished outfits</h2><div id="outs"></div>
|
|
<div class="note">these GLBs drop straight into thriftgod / djsim / procity — or publish to 3GOD.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="foot"><span id="job">ready.</span></div>
|
|
</div>
|
|
|
|
<script type="importmap">
|
|
{ "imports": {
|
|
"three": "https://unpkg.com/three@0.175.0/build/three.module.js",
|
|
"three/addons/": "https://unpkg.com/three@0.175.0/examples/jsm/"
|
|
}}
|
|
</script>
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
|
|
const $ = id => document.getElementById(id);
|
|
let LIB = {}, BODY = null, GARM = null, bodyRoot = null, garmRoot = null, mixer = null, clock = new THREE.Clock();
|
|
let bones = [], attached = [], picked = new Set(); // picked = fitted garments ticked for assemble
|
|
|
|
// ---------- stage ----------
|
|
const stage = $('stage');
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
stage.appendChild(renderer.domElement);
|
|
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x191610);
|
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100); camera.position.set(1.6, 1.5, 2.4);
|
|
const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0.9, 0);
|
|
scene.add(new THREE.HemisphereLight(0xfff4e0, 0x33291a, 1.3));
|
|
const sun = new THREE.DirectionalLight(0xffffff, 2.2); sun.position.set(2, 4, 3); scene.add(sun);
|
|
const grid = new THREE.GridHelper(4, 16, 0x554a2a, 0x2a2517); scene.add(grid);
|
|
function resize() {
|
|
const w = stage.clientWidth, h = stage.clientHeight;
|
|
renderer.setSize(w, h); camera.aspect = w / h; camera.updateProjectionMatrix();
|
|
}
|
|
new ResizeObserver(resize).observe(stage);
|
|
(function tick() { requestAnimationFrame(tick); if (mixer) mixer.update(clock.getDelta()); else clock.getDelta(); controls.update(); renderer.render(scene, camera); })();
|
|
|
|
const loader = new GLTFLoader();
|
|
const loadGlb = p => new Promise((res, rej) => loader.load('/glb?p=' + encodeURIComponent(p), res, undefined, rej));
|
|
|
|
function frameObject(root) {
|
|
const box = new THREE.Box3().setFromObject(root);
|
|
const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3()).length() || 1;
|
|
controls.target.copy(c); camera.position.set(c.x + s * 0.7, c.y + s * 0.35, c.z + s * 0.9);
|
|
}
|
|
|
|
async function setBody(item) {
|
|
BODY = item; renderLists();
|
|
if (bodyRoot) scene.remove(bodyRoot);
|
|
attached.forEach(a => a.parent && a.parent.remove(a)); attached = []; mixer = null;
|
|
$('hud').textContent = 'loading ' + item.name + '…';
|
|
try {
|
|
const g = await loadGlb(item.path);
|
|
bodyRoot = g.scene; scene.add(bodyRoot);
|
|
bones = []; bodyRoot.traverse(o => { if (o.isBone) bones.push(o); });
|
|
let skinned = 0, tris = 0;
|
|
bodyRoot.traverse(o => { if (o.isSkinnedMesh) skinned++; if (o.isMesh && o.geometry.index) tris += o.geometry.index.count / 3; });
|
|
$('boneSel').innerHTML = '<option>—</option>' + bones.map(b => `<option>${b.name}</option>`).join('');
|
|
// preselect the fun ones
|
|
for (const want of ['Head', 'Spine2', 'Hips']) {
|
|
const b = bones.find(b => b.name.toLowerCase().includes(want.toLowerCase()));
|
|
if (b) { $('boneSel').value = b.name; break; }
|
|
}
|
|
if (g.animations.length) {
|
|
mixer = new THREE.AnimationMixer(bodyRoot);
|
|
mixer.clipAction(g.animations[0]).play();
|
|
}
|
|
const box = new THREE.Box3().setFromObject(bodyRoot);
|
|
const h = (box.max.y - box.min.y).toFixed(2);
|
|
$('hud').innerHTML = `<b>${item.name}</b> · ${Math.round(tris).toLocaleString()} tris · ${h}m tall · ` +
|
|
(bones.length ? `RIGGED (${bones.length} bones${g.animations.length ? ', ' + g.animations.length + ' clip' : ''})` : '<span style="color:#e88">unrigged — rigid attach & scale only, FIT needs a rig</span>');
|
|
frameObject(bodyRoot);
|
|
} catch (e) { $('hud').textContent = 'load failed: ' + (e.message || e); }
|
|
}
|
|
|
|
async function previewGarment(item) {
|
|
GARM = item; renderLists();
|
|
if (garmRoot) { scene.remove(garmRoot); garmRoot = null; }
|
|
if (!item.path.match(/\.(glb|gltf|fbx|obj)$/i)) return; // textures: no 3D preview
|
|
try {
|
|
const g = await loadGlb(item.path);
|
|
garmRoot = g.scene; scene.add(garmRoot);
|
|
garmRoot.position.x = 1.2; // beside the body until fitted/attached
|
|
} catch (e) { $('hud').textContent = 'garment load failed: ' + (e.message || e); }
|
|
}
|
|
|
|
// rigid attach preview: clone garment scene onto the chosen bone
|
|
$('attachBtn').onclick = () => {
|
|
if (!garmRoot || !bones.length) return toast('need a rigged body + a 3D garment selected');
|
|
const bone = bones.find(b => b.name === $('boneSel').value);
|
|
if (!bone) return toast('pick a bone');
|
|
const inst = garmRoot.clone();
|
|
const s = +$('atScale').value;
|
|
inst.scale.setScalar(s); inst.position.set(0, +$('atY').value, +$('atZ').value);
|
|
bone.add(inst); attached.push(inst);
|
|
toast(`attached to ${bone.name} — preview only (bake via fleet /rig for keeps)`);
|
|
};
|
|
$('clearAtBtn').onclick = () => { attached.forEach(a => a.parent && a.parent.remove(a)); attached = []; };
|
|
|
|
// ---------- library ----------
|
|
async function refresh() {
|
|
const r = await fetch('/api/lib').then(r => r.json());
|
|
LIB = r.lib;
|
|
$('farm').textContent = (r.blender ? 'blender ✓' : 'blender ✗') +
|
|
' · flux ' + (r.cf ? 'cf ✓' : r.mb ? 'farm ✓' : '✗') + ' · farm3D ' + (r.mb ? '✓' : 'token missing');
|
|
renderLists();
|
|
}
|
|
function rowHtml(it, kind) {
|
|
const on = (kind === 'bodies' && BODY && BODY.path === it.path) || (kind !== 'bodies' && GARM && GARM.path === it.path);
|
|
const tick = kind === 'garments' && it.name.endsWith('-fitted.glb')
|
|
? `<input type="checkbox" data-pick="${it.path}" ${picked.has(it.path) ? 'checked' : ''} onclick="event.stopPropagation()">` : '';
|
|
return `<div class="item ${on ? 'on' : ''}" data-k="${kind}" data-p="${it.path}">
|
|
${tick}<span class="nm">${it.name}</span><span class="meta">${it.kb}kb · ${it.home}</span></div>`;
|
|
}
|
|
function renderLists() {
|
|
$('bodies').innerHTML = (LIB.bodies || []).map(i => rowHtml(i, 'bodies')).join('') || '<div class="note">drop a GLB/FBX above</div>';
|
|
$('garments').innerHTML = (LIB.garments || []).map(i => rowHtml(i, 'garments')).join('') || '<div class="note">none yet — generate one →</div>';
|
|
$('genLib').innerHTML = (LIB.gen || []).map(i => rowHtml(i, 'gen')).join('') || '<div class="note">nothing generated yet</div>';
|
|
$('outs').innerHTML = (LIB.out || []).map(i => rowHtml(i, 'out')).join('') || '<div class="note">no outfits assembled yet</div>';
|
|
document.querySelectorAll('.item').forEach(el => el.onclick = () => {
|
|
const it = { name: el.querySelector('.nm').textContent, path: el.dataset.p };
|
|
if (el.dataset.k === 'bodies') setBody(it);
|
|
else if (el.dataset.k === 'gen') { GENPICK = it.path; $('genPrev').src = '/file?p=' + encodeURIComponent(it.path); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; $('to3dBtn').disabled = $('hangBtn').disabled = !it.path.endsWith('-cut.png'); }
|
|
else previewGarment(it);
|
|
});
|
|
document.querySelectorAll('[data-pick]').forEach(cb => cb.onchange = () => {
|
|
cb.checked ? picked.add(cb.dataset.pick) : picked.delete(cb.dataset.pick);
|
|
$('fitList').textContent = picked.size ? [...picked].map(p => p.split('/').pop()).join(' + ') : 'tick fitted items in the garments list…';
|
|
});
|
|
}
|
|
|
|
// uploads
|
|
function wireUpload(inputId, to) {
|
|
$(inputId).onchange = async e => {
|
|
const f = e.target.files[0]; if (!f) return;
|
|
await fetch(`/api/upload?to=${to}&name=${encodeURIComponent(f.name)}`, { method: 'POST', body: await f.arrayBuffer() });
|
|
toast('uploaded ' + f.name); refresh();
|
|
};
|
|
}
|
|
wireUpload('upBody', 'bodies'); wireUpload('upGarm', 'garments');
|
|
|
|
// ---------- jobs ----------
|
|
function toast(t) { $('job').textContent = t; }
|
|
async function runJob(url, payload, label) {
|
|
toast(label + '…');
|
|
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(r => r.json());
|
|
if (r.error) { toast('✗ ' + r.error); return null; }
|
|
while (true) {
|
|
await new Promise(res => setTimeout(res, 1500));
|
|
const j = await fetch('/api/job/' + r.job).then(r => r.json());
|
|
toast(`${label}: ${j.status}${j.note ? ' · ' + j.note : ''}`);
|
|
if (j.status === 'done') { toast(`✓ ${label} → ${(j.out || '').split('/').pop()}`); refresh(); return j.out; }
|
|
if (j.status === 'error') { toast('✗ ' + label + ': ' + (j.log || '').split('\n').pop()); return null; }
|
|
}
|
|
}
|
|
$('scaleBtn').onclick = () => BODY && runJob('/api/blender', { op: 'scale', args: { path: BODY.path, height: +$('scaleH').value } }, 'scale');
|
|
$('decBtn').onclick = () => BODY && runJob('/api/blender', { op: 'decimate', args: { path: BODY.path, tris: +$('decT').value } }, 'decimate');
|
|
$('fitBtn').onclick = () => (BODY && GARM) ? runJob('/api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'garment', inflate: +$('fitInf').value } }, 'fit') : toast('pick a body AND a garment');
|
|
$('fitMergeBtn').onclick = () => (BODY && GARM) ? runJob('/api/blender', { op: 'fit', args: { body: BODY.path, garment: GARM.path, mode: 'merge', inflate: +$('fitInf').value } }, 'fit+merge') : toast('pick a body AND a garment');
|
|
$('asmBtn').onclick = () => (BODY && picked.size) ? runJob('/api/blender', { op: 'assemble', args: { body: BODY.path, garments: [...picked], name: $('outfitName').value } }, 'assemble') : toast('pick a body + tick fitted garments');
|
|
|
|
// generator chain
|
|
let GENPICK = null;
|
|
$('genBtn').onclick = async () => {
|
|
const p = $('genPrompt').value.trim(); if (!p) return;
|
|
const out = await runJob('/api/gen', { prompt: p }, 'flux');
|
|
if (out) { GENPICK = out; $('genPrev').src = '/file?p=' + encodeURIComponent(out) + '&t=' + Date.now(); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; }
|
|
};
|
|
$('rmbgBtn').onclick = async () => {
|
|
if (!GENPICK) return;
|
|
const out = await runJob('/api/rmbg', { path: GENPICK }, 'cutout');
|
|
if (out) { GENPICK = out; $('genPrev').src = '/file?p=' + encodeURIComponent(out) + '&t=' + Date.now(); $('to3dBtn').disabled = $('hangBtn').disabled = false; }
|
|
};
|
|
$('to3dBtn').onclick = () => GENPICK && runJob('/api/to3d', { path: GENPICK }, 'trellis');
|
|
$('hangBtn').onclick = async () => {
|
|
if (!GENPICK) return;
|
|
const r = await fetch('/api/hangable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: GENPICK }) }).then(r => r.json());
|
|
toast(r.note || r.error); refresh();
|
|
};
|
|
|
|
// tabs + how
|
|
document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => {
|
|
document.querySelectorAll('.tabs button').forEach(x => x.classList.remove('on')); b.classList.add('on');
|
|
for (const t of ['wardrobe', 'generate', 'out']) $('tab-' + t).style.display = b.dataset.tab === t ? 'block' : 'none';
|
|
});
|
|
$('howBtn').onclick = () => $('how').style.display = $('how').style.display === 'block' ? 'none' : 'block';
|
|
|
|
refresh(); resize();
|
|
</script>
|