wardrobegod/web/index.html
type-two 5c80e65d58 Salvage the SD lane: patch MODELBEAST, add the SD backend, fix the assets filter at source
The LoRA bench concluded the SD1.5 lane was invalid for garments. That was true of the
CHECKPOINT, not the lane: Hyper_Realism_1.2 was the only one the farm could reach and it is a
person-photoreal merge that framed every garment as worn-and-cropped no matter how hard you
negative-prompted. Given four non-person checkpoints, the lane works — and now beats flux.

MODELBEAST patches (John's explicit blessing; flux_local deliberately untouched because a
parallel session owned it and had already deployed LoRA + Z-Image support there):
· comfyui_sd now STACKS LoRAs. It hard-coded a single LoraLoader, so style+texture together
  was impossible; `lora` now takes a list, each entry optionally `name=0.6`, chained.
· comfyui_sd gains ControlNet (loader + ApplyAdvanced + staged control image). The payoff for
  a wardrobe is recolouring one garment into N colourways that share a byte-identical
  silhouette, so a single cut-out alpha is reusable across every variant.
· Per-node preflight widened to LoRAs and ControlNets, and the arch-mismatch warning now fires
  per LoRA. A wrong-base LoRA is a SILENT no-op, so everything fails loudly instead.
· Deployed to all three nodes that run comfyui_sd (m3ultra, ultra=m1, m4pro=m4), each backed up.
· GET /api/assets now honours ?parent_job=. It declared no such parameter, so FastAPI silently
  dropped it and every caller got the whole table — 2,578 rows for a bogus id. Callers that
  took rows[0] were right only by accident of newest-first ordering. Verified: bogus -> 0 rows,
  real -> only its own. Restarted the queue in a zero-active-jobs window.
· Copied 4 non-person checkpoints ultra -> m3ultra over the fast LAN (12.6 GB).

wardrobegod:
· `sd` backend on /api/gen, defaulting to juggernautXL_ragnarok, with checkpoint/lora/negative
  passthrough. Measured on one prompt+seed: a full isolated flat-lay where Hyper_Realism crops
  a worn garment.
· flat3d template gains 'no hanger, no coat hanger' — a wire hanger appeared in most SD product
  shots and keys into the cutout as if it were garment. doll2d already had the clause.

Verified end-to-end: 7/7 salvage cells ran (including a 2-LoRA stack, previously impossible),
and a Coogi knit generated through wardrobegod's own /api/gen came back isolated on white with
legible raised-knit texture.

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

597 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 }
/* transport: clip picker + scrub, pinned to the bottom of the stage */
#transport { position:absolute; left:10px; right:10px; bottom:10px; display:none; gap:8px; align-items:center;
background:#0e0c08e6; border:1px solid var(--line); border-radius:10px; padding:7px 10px; z-index:4 }
#transport.on { display:flex }
#transport select { width:auto; min-width:150px; max-width:34% }
#transport #scrub { flex:1; min-width:60px; padding:0 }
#transport #speed { width:88px; padding:0 } #transport .sub { color:var(--dim); font-size:11px; white-space:nowrap }
#transport button { min-width:34px }
/* flat (2D cut-out) garment preview — paper-doll tier has no 3D to show */
#flatPrev { position:absolute; right:12px; bottom:64px; width:190px; border:1px solid var(--line);
border-radius:10px; background:#0e0c08cc; display:none; z-index:4 }
.swatches { display:flex; gap:5px; flex-wrap:wrap; margin:5px 0 }
.sw { width:22px; height:22px; border-radius:6px; border:1px solid var(--line); cursor:pointer; padding:0 }
.sw.on { border-color:var(--gold); box-shadow:0 0 0 2px #e8c25744 }
.fitchip { margin:3px 4px 0 0; font-weight:400 }
/* 2D paper-doll stage — takes over the viewport while the doll tab is open */
#dollStage { position:absolute; inset:0; display:none; align-items:center; justify-content:center;
background:#14120e; z-index:3 }
#dollStage.on { display:flex }
#dollPrev { max-height:96%; max-width:60%; image-rendering:auto }
.slotrow { display:flex; align-items:center; gap:6px; margin:5px 0 }
.slotrow label { width:56px; color:var(--dim); font-size:12px; text-transform:uppercase; letter-spacing:1px }
.slotrow select { flex:1; min-width: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="transport">
<button id="playBtn" class="ghost">❚❚</button>
<select id="clipSel"><option>— no clips —</option></select>
<input id="scrub" type="range" min="0" max="1000" value="0" step="1">
<span id="clipTime" class="sub">0.0s</span>
<label class="sub">speed</label><input id="speed" type="range" min="0" max="2" step="0.05" value="1">
</div>
<img id="flatPrev" alt="">
<div id="dollStage"><img id="dollPrev" alt="paper doll"></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="doll">doll 2D</button>
<button data-tab="generate">generate</button>
<button data-tab="out">outfits</button>
</div>
<div id="tab-doll" style="display:none">
<h2>paper doll — change clothes</h2>
<div class="note" id="dollCount"></div>
<div id="dollSlots"></div>
<div class="row">
<button id="dollRand" class="ghost">randomise</button>
<button id="dollStrip" class="ghost">strip</button>
<button id="dollExport">export → djsim</button>
</div>
<h2>generate a new layer</h2>
<div class="row"><label>slot</label>
<select id="dollGenSlot"><option>top</option><option>bottom</option><option>shoes</option><option>hat</option><option>bag</option></select></div>
<textarea id="dollGenPhrase" rows="2" placeholder="e.g. red and black checked flannelette shirt"></textarea>
<div class="row"><button id="dollGenBtn">generate layer</button></div>
<div class="note">flux on <b>solid green</b> → farm RMBG-2.0 → staged to 704×1408.
Green is deliberate: grey/checkerboard backgrounds eat garments in the key. No text — flux can't spell.</div>
</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>
<div class="swatches" id="tintSw"></div>
<div class="note">swatch retints the last attached item (free colourways — no re-gen).</div>
<div class="row"><input id="fitName" placeholder="save dress-up as…"><button id="saveFitBtn" class="ghost">save</button></div>
<div id="savedFits" class="note"></div>
<h2>reskin — paint it on</h2>
<div class="row"><button id="reskinBtn">RESKIN body + garment</button></div>
<div class="row"><label>costume</label><select id="costumeSel"><option value="">— bare —</option></select></div>
<div class="note">Bakes the garment onto the body's own UV atlas, so it works on unstructured
TRELLIS meshes with no fitting. Silhouette doesn't change — great for tight/printed garments,
wrong for a big coat. Swapping the dropdown is instant (no re-bake).</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="2" placeholder="e.g. brown corduroy jacket / bucket hat / white leather sneakers"></textarea>
<div class="row"><label>template</label>
<select id="genTpl"><option value="flat3d">flat3d — product shot (→3D)</option>
<option value="doll2d">doll2d — painterly (→doll)</option>
<option value="doll2d_green">doll2d_green — plaid/busy</option></select></div>
<div class="row"><label>backend</label>
<select id="genBackend"><option value="klein">klein-4b — sharper fabric</option>
<option value="sd">juggernautXL — product/material</option>
<option value="cf">cf schnell — 2s, softer</option></select></div>
<div class="row"><label>variant</label><input id="genVariant" value="0" style="width:56px">
<button id="genReroll" class="ghost">↻ reroll</button></div>
<div class="row"><button id="genBtn">flux it</button><span class="note" id="genNote"></span></div>
<div class="note" id="genMeta"></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
let clips = [], action = null, playing = true, scrubbing = false; // clip bank + current action
// ---------- 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);
const dt = clock.getDelta();
if (mixer && playing && !scrubbing) mixer.update(dt * (+$('speed').value));
if (action && !scrubbing) { // keep the scrub head following playback
const d = action.getClip().duration || 1;
$('scrub').value = Math.round((action.time % d) / d * 1000);
$('clipTime').textContent = (action.time % d).toFixed(1) + 's / ' + d.toFixed(1) + 's';
}
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; }
}
// clip bank: the mixer used to blind-play animations[0] with no way to pick another.
clips = g.animations || []; action = null; mixer = null;
$('clipSel').innerHTML = clips.length
? clips.map((c, i) => `<option value="${i}">${c.name || 'clip ' + (i + 1)}</option>`).join('')
: '<option>— no clips —</option>';
$('transport').classList.toggle('on', clips.length > 0);
if (clips.length) { mixer = new THREE.AnimationMixer(bodyRoot); playClip(0); }
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); }
}
// ---------- clip transport ----------
function playClip(i) {
if (!mixer || !clips[i]) return;
if (action) action.fadeOut(0.2);
action = mixer.clipAction(clips[i]);
action.reset().fadeIn(0.2).play();
playing = true; $('playBtn').textContent = '❚❚';
$('clipSel').value = String(i);
}
$('clipSel').onchange = e => playClip(+e.target.value);
$('playBtn').onclick = () => { playing = !playing; $('playBtn').textContent = playing ? '❚❚' : '▶'; };
$('scrub').oninput = e => { // scrub = drive the mixer by hand
if (!action) return;
scrubbing = true;
const d = action.getClip().duration || 1;
action.time = (+e.target.value / 1000) * d;
mixer.update(0);
$('clipTime').textContent = action.time.toFixed(1) + 's / ' + d.toFixed(1) + 's';
};
$('scrub').onchange = () => { scrubbing = false; };
async function previewGarment(item) {
GARM = item; renderLists();
if (garmRoot) { scene.remove(garmRoot); garmRoot = null; }
if (!item.path.match(/\.(glb|gltf|fbx|obj)$/i)) { // flat cut-out: show the 2D tier
$('flatPrev').src = '/file?p=' + encodeURIComponent(item.path);
$('flatPrev').style.display = 'block';
return;
}
$('flatPrev').style.display = 'none';
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(true);
const s = +$('atScale').value;
inst.scale.setScalar(s); inst.position.set(0, +$('atY').value, +$('atZ').value);
// clone materials so a tint on this item doesn't bleed into every other instance
inst.traverse(o => { if (o.isMesh && o.material) o.material = o.material.clone(); });
inst.userData.wg = { path: GARM.path, name: GARM.name, bone: bone.name,
scale: s, y: +$('atY').value, z: +$('atZ').value };
bone.add(inst); attached.push(inst);
toast(`attached ${GARM.name}${bone.name} · save the dress-up to keep it`);
};
$('clearAtBtn').onclick = () => { attached.forEach(a => a.parent && a.parent.remove(a)); attached = []; };
// ---------- colourways: retint the last attached item (free variants, no re-gen) ----------
const SWATCHES = ['#ffffff', '#c8452e', '#2f5d8a', '#3f7a4a', '#d8a33a', '#6c4a86', '#2b2b2b', '#b8895f'];
$('tintSw').innerHTML = SWATCHES.map(c => `<button class="sw" data-c="${c}" style="background:${c}"></button>`).join('');
$('tintSw').onclick = e => {
const b = e.target.closest('.sw'); if (!b) return;
const tgt = attached[attached.length - 1];
if (!tgt) return toast('attach something first, then pick a colour');
document.querySelectorAll('.sw').forEach(s => s.classList.toggle('on', s === b));
tgt.traverse(o => { if (o.isMesh && o.material && o.material.color) o.material.color.set(b.dataset.c); });
tgt.userData.wg.tint = b.dataset.c;
toast('tinted ' + (tgt.userData.wg.name || '') + ' → ' + b.dataset.c);
};
// ---------- save / load a dress-up ----------
$('saveFitBtn').onclick = async () => {
if (!BODY) return toast('pick a body first');
const attach = {};
attached.forEach((a, i) => { const w = a.userData.wg || {}; attach[(w.name || 'item') + '#' + i] = w; });
const r = await fetch('/api/outfit', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: $('fitName').value || 'dressup', body: BODY.path,
anim: clips[+$('clipSel').value]?.name, attach }) }).then(r => r.json());
toast(r.error ? '✗ ' + r.error : `✓ saved dress-up "${r.name}" (${Object.keys(attach).length} item/s)`);
refresh();
};
async function loadFit(name) {
const f = await fetch('/api/outfit/' + encodeURIComponent(name)).then(r => r.json());
if (f.error) return toast('✗ ' + f.error);
const bodyItem = (LIB.bodies || []).find(b => b.path === f.body) || { name: f.body.split('/').pop(), path: f.body };
await setBody(bodyItem);
for (const w of Object.values(f.attach || {})) { // re-hang every saved attachment
const bone = bones.find(b => b.name === w.bone); if (!bone) continue;
try {
const g = await loadGlb(w.path);
const inst = g.scene;
inst.scale.setScalar(w.scale || 1); inst.position.set(0, w.y || 0, w.z || 0);
inst.traverse(o => { if (o.isMesh && o.material) { o.material = o.material.clone();
if (w.tint && o.material.color) o.material.color.set(w.tint); } });
inst.userData.wg = w; bone.add(inst); attached.push(inst);
} catch (e) { /* missing attachment file — skip it, the rest of the fit still loads */ }
}
if (f.anim) { const i = clips.findIndex(c => c.name === f.anim); if (i >= 0) playClip(i); }
toast(`✓ loaded "${name}" — ${attached.length} item/s`);
}
// ---------- library ----------
async function refresh() {
const r = await fetch('/api/lib').then(r => r.json());
LIB = r.lib;
LIB.doll = r.doll || {}; // doll catalogue rides alongside lib, not inside it
$('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>';
// NB: deliberately NOT class="item" — the generic .item handler below would clobber this one
$('savedFits').innerHTML = (LIB.outfits || []).length
? (LIB.outfits || []).map(n => `<button class="ghost fitchip" data-fit="${n}">${n}</button>`).join('')
: 'no saved dress-ups yet';
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('.fitchip').forEach(el => el.onclick = () => loadFit(el.dataset.fit));
renderCostumes();
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;
async function doGen() {
const p = $('genPrompt').value.trim(); if (!p) return toast('describe the garment');
const payload = { phrase: p, template: $('genTpl').value, backend: $('genBackend').value,
variant: +$('genVariant').value || 0, view: 'top' };
// show the resolved prompt + seed: the cheapest debugging affordance there is, and without it
// a bad generation gives you nothing to reason about
const pre = await fetch('/api/gen', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) }).then(r => r.json());
if (pre.error) return toast('✗ ' + pre.error);
$('genMeta').textContent = `seed ${pre.seed} · ${pre.prompt}`;
toast('flux…');
while (true) {
await new Promise(r => setTimeout(r, 1500));
const j = await fetch('/api/job/' + pre.job).then(r => r.json());
toast(`flux: ${j.status}${j.note ? ' · ' + j.note : ''}`);
if (j.status === 'error') return toast('✗ flux: ' + (j.log || '').split('\n').pop());
if (j.status === 'done') {
GENPICK = j.out;
$('genPrev').src = '/file?p=' + encodeURIComponent(j.out) + '&t=' + Date.now();
$('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false;
refresh(); return toast('✓ ' + (j.out || '').split('/').pop());
}
}
}
$('genBtn').onclick = doGen;
$('genReroll').onclick = () => { $('genVariant').value = (+$('genVariant').value || 0) + 1; doGen(); };
$('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();
};
// ---------- reskin: swap the body's own texture ----------
// The bake is the expensive part and it already happened; changing costume is just a map swap,
// so pre-baked outfits toggle instantly. Keeps the original map so "bare" is reversible.
const texLoader = new THREE.TextureLoader();
$('reskinBtn').onclick = () => (BODY && GARM)
? runJob('/api/reskin', { body: BODY.path, garment: GARM.path, desc: 'the ' + GARM.name.replace(/[-_]/g, ' ').replace(/\.\w+$/, '') }, 'reskin')
: toast('pick a body AND a garment first');
function renderCostumes() {
const mine = (LIB.garments || []).filter(g => g.name.endsWith('.reskin.png'));
$('costumeSel').innerHTML = '<option value="">— bare —</option>' +
mine.map(g => `<option value="${g.path}">${g.name.replace('.reskin.png', '')}</option>`).join('');
}
$('costumeSel').onchange = e => {
if (!bodyRoot) return toast('load a body first');
const p = e.target.value;
bodyRoot.traverse(o => {
if (!o.isMesh || !o.material) return;
if (o.userData.bareMap === undefined) o.userData.bareMap = o.material.map || null;
if (!p) { o.material.map = o.userData.bareMap; o.material.needsUpdate = true; return; }
texLoader.load('/file?p=' + encodeURIComponent(p) + '&t=' + Date.now(), t => {
t.flipY = false; t.colorSpace = THREE.SRGBColorSpace; // glTF UV convention
o.material.map = t; o.material.needsUpdate = true;
});
});
toast(p ? 'wearing ' + p.split('/').pop().replace('.reskin.png', '') : 'bare');
};
// ---------- 2D paper-doll tier ----------
// The flat cut-outs are a first-class product, not a fallback: djsim already ships 441 of
// them and its loader self-heals on any dropped PNG, so a layer generated here wears itself
// 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'];
let DOLLPICK = {};
function renderDoll() {
const cat = LIB.doll || {};
const total = DOLL_SLOTS.reduce((n, s) => n + (cat[s] || []).length, 0);
$('dollCount').textContent = total ? `${total} layers · ${DOLL_SLOTS.map(s => `${(cat[s] || []).length} ${s}`).join(' · ')}` : 'no doll layers yet';
$('dollSlots').innerHTML = DOLL_SLOTS.map(s => {
const items = cat[s] || [];
return `<div class="slotrow"><label>${s}</label><select data-slot="${s}" ${items.length ? '' : 'disabled'}>
<option value="">— none —</option>
${items.map(i => `<option value="${i.stem}" ${DOLLPICK[s] === i.stem ? 'selected' : ''}>${i.key}</option>`).join('')}
</select></div>`;
}).join('');
document.querySelectorAll('#dollSlots select').forEach(sel => sel.onchange = () => {
const v = sel.value; if (v) DOLLPICK[sel.dataset.slot] = v; else delete DOLLPICK[sel.dataset.slot];
composeDoll();
});
}
async function composeDoll() {
const stems = Object.values(DOLLPICK);
const r = await fetch('/api/doll/compose', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stems, name: 'preview' }) }).then(r => r.json());
if (r.error) return toast('✗ ' + r.error);
$('dollPrev').src = '/file?p=' + encodeURIComponent(r.path) + '&t=' + Date.now();
toast(`doll: ${stems.length} layer/s`);
}
$('dollRand').onclick = () => {
const cat = LIB.doll || {};
DOLLPICK = {};
for (const s of ['top', 'bottom', 'shoes', 'hat']) {
const items = cat[s] || [];
if (items.length) DOLLPICK[s] = items[Math.floor(Math.random() * items.length)].stem;
}
renderDoll(); composeDoll();
};
$('dollStrip').onclick = () => { DOLLPICK = {}; renderDoll(); composeDoll(); };
$('dollGenBtn').onclick = async () => {
const phrase = $('dollGenPhrase').value.trim(); if (!phrase) return toast('describe the garment');
const slot = $('dollGenSlot').value;
const out = await runJob('/api/doll/gen', { slot, phrase, key: phrase }, `doll ${slot}`);
if (out) { await refresh(); renderDoll(); }
};
$('dollExport').onclick = async () => {
const stems = Object.values(DOLLPICK);
if (!stems.length) return toast('dress the doll first');
const r = await fetch('/api/doll/export', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stems, dry: true }) }).then(r => r.json());
if (r.error) return toast('✗ ' + r.error);
toast(r.note || 'exported');
};
// 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', 'doll', 'generate', 'out']) $('tab-' + t).style.display = b.dataset.tab === t ? 'block' : 'none';
const onDoll = b.dataset.tab === 'doll';
$('dollStage').classList.toggle('on', onDoll);
$('transport').classList.toggle('on', !onDoll && clips.length > 0);
if (onDoll) { renderDoll(); if (!$('dollPrev').src) composeDoll(); }
});
$('howBtn').onclick = () => $('how').style.display = $('how').style.display === 'block' ? 'none' : 'block';
refresh(); resize();
</script>