← back to Whatsmystyle
feat: avatar + Dopple try-on + Time-Travel Wardrobe
4a4d19ee43a0bef68783ee907fdcdbe567781889 · 2026-05-11 19:54:44 -0700 · Steve Abrams
- Digital-twin avatar built from 3-8 user photos
- Provider chain: Dopple → Gemini 2.5 Flash Image → IDM-VTON → OOTD → stub
- Dopple persistent twin_id stored in user_avatars.body_measurements
- 'Try on me' button on every duel + rec card
- Time-Travel Wardrobe: pick closet item + drop old photo + tag occasion
→ Dopple /v1/scenes/edit re-dresses past-you, preserving face/others/venue
- New tables: user_avatars, tryon_jobs (with closet_id, occasion columns)
- Idempotent ALTER TABLE migration helper for SQLite
The killer feature: 'I wish I'd worn the navy gown to my sister's wedding'
→ select gown from closet → upload wedding photo → pick 'wedding'
→ Dopple swaps just the outfit, keeps everything else.
Files touched
M data/whatsmystyle.db-shmM data/whatsmystyle.db-walM docs/SPEC.mdM package-lock.jsonM package.jsonM public/css/app.cssM public/index.htmlM public/js/app.jsA scripts/avatar-build.jsA scripts/dopple.jsA scripts/tryon-worker.jsM server.js
Diff
commit 4a4d19ee43a0bef68783ee907fdcdbe567781889
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 19:54:44 2026 -0700
feat: avatar + Dopple try-on + Time-Travel Wardrobe
- Digital-twin avatar built from 3-8 user photos
- Provider chain: Dopple → Gemini 2.5 Flash Image → IDM-VTON → OOTD → stub
- Dopple persistent twin_id stored in user_avatars.body_measurements
- 'Try on me' button on every duel + rec card
- Time-Travel Wardrobe: pick closet item + drop old photo + tag occasion
→ Dopple /v1/scenes/edit re-dresses past-you, preserving face/others/venue
- New tables: user_avatars, tryon_jobs (with closet_id, occasion columns)
- Idempotent ALTER TABLE migration helper for SQLite
The killer feature: 'I wish I'd worn the navy gown to my sister's wedding'
→ select gown from closet → upload wedding photo → pick 'wedding'
→ Dopple swaps just the outfit, keeps everything else.
---
data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
data/whatsmystyle.db-wal | Bin 90672 -> 135992 bytes
docs/SPEC.md | 33 +++++++++
package-lock.json | 74 +++++++++++++++++++
package.json | 9 +--
public/css/app.css | 20 ++++++
public/index.html | 53 ++++++++++++++
public/js/app.js | 152 +++++++++++++++++++++++++++++++++++++++
scripts/avatar-build.js | 174 +++++++++++++++++++++++++++++++++++++++++++++
scripts/dopple.js | 115 ++++++++++++++++++++++++++++++
scripts/tryon-worker.js | 180 +++++++++++++++++++++++++++++++++++++++++++++++
server.js | 147 ++++++++++++++++++++++++++++++++++++++
12 files changed, 953 insertions(+), 4 deletions(-)
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index a6d8b8d..fa48716 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index eeae526..755fd86 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 92b23e8..cc9077b 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -65,6 +65,39 @@ Every significant design / spec decision is debated by the dream team (claude-co
- Location requested at the moment it's needed, not at onboarding.
- All taste data stored locally in SQLite. No cloud sync until Steve approves.
+## Avatar + Virtual Try-On (the magic)
+
+Three modes, all powered by the same digital-twin layer:
+
+### 1. Avatar build
+- User uploads 3–8 photos in `My Avatar`
+- We POST them to **Dopple** (`POST /v1/twins`) → persistent `twin_id`
+- Body proportions extracted via local `llava:13b` for sizing recommendations
+- Once `status='ready'`, every screen in the app gets a "Try on me" button
+
+### 2. "Try on me" (catalog item on YOUR avatar)
+- Click the button on any duel card or recommendation
+- Backend → Dopple `/v1/tryon { twin_id, garment_url }`
+- Result lands in **My Try-Ons** in ~30s
+
+### 3. Time-Travel Wardrobe (the killer feature)
+The use case that pitched itself: *"I wish I'd worn this dress to my sister's wedding."*
+
+- User picks a piece from **their actual closet** (a previous upload)
+- User drops the **old photo** (the wedding, the graduation, prom)
+- User taps the occasion pill (or types it)
+- Backend → Dopple `/v1/scenes/edit { twin_id, source_image, garment, scene_hint }`
+ - `preserve_identity=1` — her face stays her face
+ - `preserve_others=1` — wedding party stays intact
+ - `preserve_background=1` — venue stays intact
+- Result: she's wearing the dress she wishes she'd worn, in the actual photo
+
+### Provider priority (top-down — first one with creds wins)
+1. **Dopple** (`DOPPLE_API_KEY`) — purpose-built, highest fidelity, persistent twin
+2. **Google Gemini 2.5 Flash Image** (`GEMINI_API_KEY`) — fallback; great for general image edit, no twin persistence
+3. **IDM-VTON** / **OOTDiffusion** (open-source, local GPU service) — fully offline path
+4. **Stub** — pipes the source through; lets the rest of the app work end-to-end
+
## Out of scope (v0.1)
- No payments / no checkout — we link out to brand / resale site
- No social features
diff --git a/package-lock.json b/package-lock.json
index bdcec62..f95681c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-session": "^1.18.0",
+ "form-data": "^4.0.5",
"multer": "^1.4.5-lts.1",
"node-fetch": "^2.7.0"
}
@@ -42,6 +43,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -216,6 +223,18 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@ -319,6 +338,15 @@
"node": ">=4.0.0"
}
},
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -427,6 +455,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -550,6 +593,22 @@
"node": ">= 0.8"
}
},
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -650,6 +709,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
diff --git a/package.json b/package.json
index 03ef25c..600313e 100644
--- a/package.json
+++ b/package.json
@@ -8,12 +8,13 @@
"dev": "node server.js"
},
"dependencies": {
+ "better-sqlite3": "^11.3.0",
+ "cookie-parser": "^1.4.6",
+ "dotenv": "^16.4.5",
"express": "^4.19.2",
"express-session": "^1.18.0",
- "cookie-parser": "^1.4.6",
- "better-sqlite3": "^11.3.0",
+ "form-data": "^4.0.5",
"multer": "^1.4.5-lts.1",
- "node-fetch": "^2.7.0",
- "dotenv": "^16.4.5"
+ "node-fetch": "^2.7.0"
}
}
diff --git a/public/css/app.css b/public/css/app.css
index 9243aac..20c9831 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -135,5 +135,25 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
.conn strong { display: block; font-size: 17px; }
.conn small { color: #777; }
+/* time-travel */
+.tt-step { margin: 22px 0; padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--paper); }
+.tt-step h3 { margin: 0 0 12px; font-weight: 600; }
+.tt-closet-pick { outline: 3px solid var(--accent); outline-offset: 2px; }
+
+/* tryon button on cards */
+.card .tryon-btn {
+ position: absolute; bottom: 12px; right: 12px;
+ background: var(--ink); color: var(--paper);
+ border: 0; border-radius: 999px; padding: 8px 14px;
+ font-size: 13px; font-weight: 600;
+ opacity: 0; transition: opacity .15s;
+}
+.card:hover .tryon-btn, .card:focus-within .tryon-btn { opacity: 1; }
+
+/* tryon result preview */
+.tryon-result { margin-top: 14px; }
+.tryon-result img { max-width: 100%; border-radius: 14px; box-shadow: var(--shadow); }
+.tryon-pending { padding: 14px; background: var(--paper-2); border-radius: 14px; color: #555; }
+
/* utils */
[hidden] { display: none !important; }
diff --git a/public/index.html b/public/index.html
index a7d921b..0295ff7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -19,6 +19,9 @@
<a href="#" data-screen="home">Duel</a>
<a href="#" data-screen="closet">My Closet</a>
<a href="#" data-screen="recs">For You</a>
+ <a href="#" data-screen="avatar">My Avatar</a>
+ <a href="#" data-screen="timetravel">Time-Travel Wardrobe</a>
+ <a href="#" data-screen="tryons">My Try-Ons</a>
<a href="#" data-screen="stores">Stores Near Me</a>
<a href="#" data-screen="connect">Connections</a>
<a href="#" data-screen="onboard">Re-onboard</a>
@@ -112,6 +115,56 @@
<div id="stores-list"></div>
</section>
+ <!-- Avatar -->
+ <section id="avatar" class="screen" hidden>
+ <h2>My Avatar</h2>
+ <p class="muted">Upload 3–5 photos of yourself (full body works best). We use Dopple to build a persistent digital twin you can dress and re-dress forever.</p>
+ <form id="avatar-form">
+ <input type="file" name="photos" accept="image/*" multiple>
+ <button type="submit">Add photos</button>
+ <button type="button" id="avatar-build-btn">Build my avatar</button>
+ </form>
+ <div id="avatar-status" class="muted"></div>
+ <div id="avatar-preview"></div>
+ </section>
+
+ <!-- Time-Travel Wardrobe (closet → old photo) -->
+ <section id="timetravel" class="screen" hidden>
+ <h2>Time-Travel Wardrobe</h2>
+ <p class="muted">Pick a piece from your closet. Pick an old photo. Tell us the occasion. We re-dress past-you in the outfit you wish you'd worn — face, friends, venue all preserved.</p>
+ <div class="tt-step">
+ <h3>1. Pick a piece from your closet</h3>
+ <div id="tt-closet" class="grid"></div>
+ </div>
+ <div class="tt-step">
+ <h3>2. Drop the old photo</h3>
+ <input type="file" id="tt-photo" accept="image/*">
+ <img id="tt-photo-preview" style="max-width:300px;border-radius:14px;margin-top:10px;display:none;">
+ </div>
+ <div class="tt-step">
+ <h3>3. What was the occasion?</h3>
+ <div class="pills">
+ <button class="pill" data-occ="wedding">Wedding</button>
+ <button class="pill" data-occ="graduation">Graduation</button>
+ <button class="pill" data-occ="prom">Prom</button>
+ <button class="pill" data-occ="reunion">Reunion</button>
+ <button class="pill" data-occ="bday">Birthday</button>
+ <button class="pill" data-occ="work">Big work moment</button>
+ <button class="pill" data-occ="firstdate">First date</button>
+ <button class="pill" data-occ="other">Other</button>
+ </div>
+ <input id="tt-occ" placeholder="Or describe it in your own words…" style="margin-top:10px;padding:14px;border-radius:12px;border:1px solid var(--line);width:100%;">
+ </div>
+ <button id="tt-go" class="pill" disabled>Re-dress me in this photo →</button>
+ <div id="tt-result"></div>
+ </section>
+
+ <!-- Try-on history -->
+ <section id="tryons" class="screen" hidden>
+ <h2>My Try-Ons</h2>
+ <div id="tryons-grid" class="grid"></div>
+ </section>
+
<!-- Connections -->
<section id="connect" class="screen" hidden>
<h2>Let me see…</h2>
diff --git a/public/js/app.js b/public/js/app.js
index ac1498a..a3702a1 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -292,12 +292,164 @@ $$('.conn').forEach(b => b.addEventListener('click', async () => {
$('#connect-state').textContent = JSON.stringify(cs.connections, null, 2);
}));
+// ---------- Avatar --------------------------------------------------------
+const avatarForm = $('#avatar-form');
+avatarForm.addEventListener('submit', async e => {
+ e.preventDefault();
+ const fd = new FormData(avatarForm);
+ const r = await fetch('/api/avatar/photos', { method: 'POST', body: fd }).then(r => r.json());
+ $('#avatar-status').textContent = `${r.photo_count} photo${r.photo_count===1?'':'s'} uploaded. Click "Build my avatar" when ready.`;
+});
+$('#avatar-build-btn').addEventListener('click', async () => {
+ $('#avatar-status').textContent = 'Building your digital twin… this can take 1–2 min.';
+ await fetch('/api/avatar/build', { method: 'POST' });
+ pollAvatar();
+});
+async function pollAvatar() {
+ for (let i = 0; i < 60; i++) {
+ const r = await fetch('/api/avatar').then(r => r.json());
+ if (r.avatar?.status === 'ready') {
+ $('#avatar-status').textContent = '✓ Avatar ready. You can now "Try this on me" anywhere in the app.';
+ $('#avatar-preview').innerHTML = `<img src="/api/avatar/render?t=${Date.now()}" style="max-width:340px;border-radius:18px;box-shadow:var(--shadow);margin-top:14px;">`;
+ return;
+ }
+ if (r.avatar?.status === 'failed') {
+ $('#avatar-status').textContent = 'Avatar build failed. Try again with clearer photos.';
+ return;
+ }
+ await new Promise(r => setTimeout(r, 4000));
+ }
+}
+
+// ---------- Try-on (from duel / recs / closet) ----------------------------
+async function tryOnItem({ item_id, closet_id, mode = 'avatar', occasion, source_photo_file }) {
+ const fd = new FormData();
+ if (item_id) fd.append('item_id', item_id);
+ if (closet_id) fd.append('closet_id', closet_id);
+ fd.append('mode', mode);
+ if (occasion) fd.append('occasion', occasion);
+ if (source_photo_file) fd.append('source_photo', source_photo_file);
+ const r = await fetch('/api/tryon', { method: 'POST', body: fd }).then(r => r.json());
+ if (r.error) { alert(r.error + (r.hint ? ' — ' + r.hint : '')); return null; }
+ return r.job_id;
+}
+
+async function pollTryon(jobId, onUpdate) {
+ for (let i = 0; i < 90; i++) {
+ const j = await fetch(`/api/tryon/${jobId}`).then(r => r.json());
+ onUpdate(j.job);
+ if (j.job.status === 'done' || j.job.status === 'failed') return j.job;
+ await new Promise(r => setTimeout(r, 3000));
+ }
+}
+
+// ---------- Time-Travel ---------------------------------------------------
+let ttClosetPick = null;
+let ttPhotoFile = null;
+let ttOccasion = null;
+async function loadTimeTravel() {
+ const r = await fetch('/api/closet').then(r => r.json());
+ const grid = $('#tt-closet');
+ grid.innerHTML = '';
+ if (!r.items?.length) {
+ grid.innerHTML = '<p class="muted">No closet items yet. Upload a closet photo first (in My Closet).</p>';
+ return;
+ }
+ r.items.forEach(it => {
+ const c = document.createElement('button');
+ c.className = 'card';
+ c.style.cursor = 'pointer';
+ c.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || '—'}</div><div class="brand">${it.color || ''}</div></div>`;
+ c.onclick = () => {
+ $$('#tt-closet .card').forEach(x => x.classList.remove('tt-closet-pick'));
+ c.classList.add('tt-closet-pick');
+ ttClosetPick = it.id;
+ checkTTReady();
+ };
+ grid.appendChild(c);
+ });
+}
+$('#tt-photo').addEventListener('change', e => {
+ ttPhotoFile = e.target.files[0];
+ if (ttPhotoFile) {
+ $('#tt-photo-preview').src = URL.createObjectURL(ttPhotoFile);
+ $('#tt-photo-preview').style.display = 'block';
+ }
+ checkTTReady();
+});
+$$('#timetravel .pills .pill').forEach(p => p.addEventListener('click', () => {
+ $$('#timetravel .pills .pill').forEach(x => x.style.background = '');
+ p.style.background = 'var(--ink)';
+ p.style.color = 'var(--paper)';
+ ttOccasion = p.dataset.occ;
+ $('#tt-occ').value = ttOccasion;
+ checkTTReady();
+}));
+$('#tt-occ').addEventListener('input', e => { ttOccasion = e.target.value; checkTTReady(); });
+function checkTTReady() {
+ $('#tt-go').disabled = !(ttClosetPick && ttPhotoFile && ttOccasion);
+}
+$('#tt-go').addEventListener('click', async () => {
+ $('#tt-result').innerHTML = '<div class="tryon-pending">Re-dressing past-you… (this can take a minute)</div>';
+ const jobId = await tryOnItem({
+ closet_id: ttClosetPick,
+ mode: 'closet_to_oldphoto',
+ occasion: ttOccasion,
+ source_photo_file: ttPhotoFile,
+ });
+ if (!jobId) return;
+ pollTryon(jobId, job => {
+ if (job.status === 'done') {
+ $('#tt-result').innerHTML = `<div class="tryon-result"><img src="/api/tryon/${jobId}/image?t=${Date.now()}"><p class="muted">Rendered via ${job.provider}.</p></div>`;
+ } else if (job.status === 'failed') {
+ $('#tt-result').innerHTML = `<div class="tryon-pending">Render failed: ${job.error || 'unknown'}</div>`;
+ }
+ });
+});
+
+// ---------- My Try-Ons ----------------------------------------------------
+async function loadTryons() {
+ const r = await fetch('/api/tryon').then(r => r.json());
+ const grid = $('#tryons-grid');
+ grid.innerHTML = '';
+ (r.jobs || []).forEach(j => {
+ const c = document.createElement('div');
+ c.className = 'card';
+ c.innerHTML = j.status === 'done'
+ ? `<img src="/api/tryon/${j.id}/image" alt=""><div class="meta"><div class="title">${j.mode}</div><div class="brand">${j.provider} · ${j.created_at}</div></div>`
+ : `<div class="tryon-pending" style="min-height:280px;display:grid;place-items:center;">${j.status}…</div><div class="meta"><div class="title">${j.mode}</div></div>`;
+ grid.appendChild(c);
+ });
+}
+
+// inject "Try on me" buttons into duel + recs after they render
+const origPaint = paintCard;
+paintCard = function(side, item) {
+ origPaint(side, item);
+ const card = document.querySelector(`.card[data-pick="${side.toUpperCase()}"]`);
+ if (!card) return;
+ card.querySelectorAll('.tryon-btn').forEach(b => b.remove());
+ const b = document.createElement('button');
+ b.className = 'tryon-btn';
+ b.textContent = 'Try on me';
+ b.onclick = (e) => {
+ e.stopPropagation();
+ tryOnItem({ item_id: item.id, mode: 'avatar' }).then(jobId => {
+ if (jobId) alert('Rendering on your avatar — check My Try-Ons in a minute.');
+ });
+ };
+ card.appendChild(b);
+};
+
// ---------- Router ---------------------------------------------------------
function route(name) {
show(name);
if (name === 'home') loadDuel();
if (name === 'closet') loadCloset();
if (name === 'recs') loadRecs();
+ if (name === 'avatar') pollAvatar();
+ if (name === 'timetravel') loadTimeTravel();
+ if (name === 'tryons') loadTryons();
if (name === 'connect') fetch('/api/connect').then(r => r.json()).then(c => $('#connect-state').textContent = JSON.stringify(c.connections, null, 2));
}
diff --git a/scripts/avatar-build.js b/scripts/avatar-build.js
new file mode 100644
index 0000000..33bc59e
--- /dev/null
+++ b/scripts/avatar-build.js
@@ -0,0 +1,174 @@
+/**
+ * Avatar builder — turns 3-8 user photos into a single canonical full-body
+ * avatar image usable as the base for virtual try-on.
+ *
+ * Strategy (tiered, picks best available):
+ *
+ * 1) GEMINI 2.5 FLASH IMAGE (Google's "Banana") — single API, edits + composites
+ * photos. Best for "swap outfit in old photo" workflows. Set GEMINI_API_KEY.
+ *
+ * 2) IDM-VTON (CVPR'24) / OOTDiffusion — open-source virtual try-on. Needs a
+ * local Python service. Set IDM_VTON_URL=http://localhost:7860 if running.
+ *
+ * 3) SAM2 + ControlNet local stack — segments the user, generates a clean
+ * front-facing full-body neutral-pose render. Set SAM2_URL.
+ *
+ * 4) Stub — picks the largest input photo and writes it through as the
+ * canonical avatar. Lets the rest of the system work end-to-end while
+ * the heavy lift is being wired.
+ *
+ * Body measurements (shoulder width, torso height, etc.) are extracted via
+ * llava-13b vision pass — same Ollama service the closet worker uses.
+ */
+const Database = require('better-sqlite3');
+const path = require('path');
+const fs = require('fs');
+const fetch = require('node-fetch');
+
+const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
+const VISION = process.env.OLLAMA_VISION_MODEL || 'llava:13b';
+const DOPPLE_KEY = process.env.DOPPLE_API_KEY;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+const IDM_URL = process.env.IDM_VTON_URL;
+const SAM2_URL = process.env.SAM2_URL;
+const dopple = require('./dopple');
+
+async function measureBody(photoPath) {
+ try {
+ const b64 = fs.readFileSync(photoPath).toString('base64');
+ const prompt = `You see a full-body photograph of a person. Estimate proportions normalized to image height = 1.0.
+Return STRICT JSON only: {"shoulder_w":0.0,"torso_h":0.0,"hip_w":0.0,"leg_h":0.0,"head_h":0.0,"pose":"front|side|3-4","clothing_visible":"top|bottom|both|partial"}`;
+ const r = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ model: VISION, prompt, images: [b64], stream: false, options: { temperature: 0.2 } }),
+ });
+ const j = await r.json();
+ const m = (j.response || '').match(/\{[\s\S]*\}/);
+ return m ? JSON.parse(m[0]) : null;
+ } catch (e) {
+ console.error('[avatar] vision err:', e.message);
+ return null;
+ }
+}
+
+async function buildViaGemini(photoPaths, outPath) {
+ // Gemini 2.5 Flash Image (formerly "Nano Banana") can ingest multiple images
+ // and composite a single canonical render. We ask for a clean full-body
+ // neutral-pose photo of the same person.
+ const images = photoPaths.slice(0, 6).map(p => ({
+ inline_data: { mime_type: 'image/jpeg', data: fs.readFileSync(p).toString('base64') }
+ }));
+ const body = {
+ contents: [{
+ parts: [
+ ...images,
+ { text: `Looking at these photos of the same person, produce ONE clean full-body photograph of that exact person, standing front-facing, neutral pose, plain pale-grey studio background, casual neutral outfit (white tee + grey pants). Preserve facial identity, hair, skin tone, body proportions. Photographic, not illustrated.` }
+ ]
+ }],
+ generationConfig: { responseModalities: ['IMAGE'] }
+ };
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_KEY}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!r.ok) throw new Error(`Gemini ${r.status}: ${await r.text()}`);
+ const j = await r.json();
+ const part = j.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+ const data = part?.inline_data?.data || part?.inlineData?.data;
+ if (!data) throw new Error('Gemini returned no image');
+ fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
+ return 'gemini-2.5-flash-image';
+}
+
+function pickStubCanonical(photoPaths, outPath) {
+ // largest file → most likely the highest-res shot
+ let best = null, bestSize = 0;
+ for (const p of photoPaths) {
+ try {
+ const s = fs.statSync(p).size;
+ if (s > bestSize) { bestSize = s; best = p; }
+ } catch {}
+ }
+ if (!best) return null;
+ fs.copyFileSync(best, outPath);
+ return 'stub';
+}
+
+async function buildOne(db, av) {
+ const photos = JSON.parse(av.source_photos || '[]').filter(p => fs.existsSync(p));
+ if (!photos.length) {
+ db.prepare("UPDATE user_avatars SET status='failed' WHERE id=?").run(av.id);
+ return;
+ }
+ const outDir = path.join(path.dirname(photos[0]), '..', 'canonical');
+ fs.mkdirSync(outDir, { recursive: true });
+ const outPath = path.join(outDir, `avatar-${av.id}.png`);
+
+ let provider = null;
+ let dopple_twin_id = null;
+ try {
+ if (DOPPLE_KEY) {
+ // Dopple-first: create a persistent twin, then ask Dopple for a canonical render
+ dopple_twin_id = await dopple.createTwin(photos);
+ await dopple.pollTwin(dopple_twin_id);
+ // Render the twin in neutral pose by doing a try-on with a "neutral basics" prompt
+ const { buffer } = await dopple.tryOn({
+ twinId: dopple_twin_id,
+ garmentUrl: null,
+ options: { pose: 'neutral_front', background: 'studio_grey', outfit: 'plain_white_tee_grey_pants' },
+ }).catch(async () => {
+ // If the neutral-render path isn't supported, just save the first photo as canonical;
+ // the twin_id is what powers all subsequent try-ons.
+ return { buffer: fs.readFileSync(photos[0]) };
+ });
+ fs.writeFileSync(outPath, buffer);
+ provider = 'dopple';
+ } else if (GEMINI_KEY) provider = await buildViaGemini(photos, outPath);
+ else if (IDM_URL) {
+ // POST to local IDM-VTON service
+ const fd = new (require('form-data'))();
+ photos.slice(0, 4).forEach((p, i) => fd.append(`photo${i}`, fs.createReadStream(p)));
+ const r = await fetch(`${IDM_URL}/build`, { method: 'POST', body: fd });
+ if (!r.ok) throw new Error(`IDM-VTON ${r.status}`);
+ const buf = Buffer.from(await r.arrayBuffer());
+ fs.writeFileSync(outPath, buf);
+ provider = 'idm-vton';
+ } else if (SAM2_URL) {
+ const fd = new (require('form-data'))();
+ photos.slice(0, 4).forEach((p, i) => fd.append(`photo${i}`, fs.createReadStream(p)));
+ const r = await fetch(`${SAM2_URL}/avatar`, { method: 'POST', body: fd });
+ if (!r.ok) throw new Error(`SAM2 ${r.status}`);
+ const buf = Buffer.from(await r.arrayBuffer());
+ fs.writeFileSync(outPath, buf);
+ provider = 'sam2-controlnet';
+ } else {
+ provider = pickStubCanonical(photos, outPath);
+ }
+ } catch (e) {
+ console.error('[avatar] build err:', e.message);
+ pickStubCanonical(photos, outPath);
+ provider = 'stub (after error)';
+ }
+ const measurements = await measureBody(outPath);
+ const meta = { ...(measurements || {}), _provider: provider };
+ if (dopple_twin_id) meta._dopple_twin_id = dopple_twin_id;
+ db.prepare(`UPDATE user_avatars SET status='ready', canonical_path=?, body_measurements=?, ready_at=datetime('now') WHERE id=?`)
+ .run(outPath, JSON.stringify(meta), av.id);
+ console.log(`[avatar] built av#${av.id} via ${provider} → ${outPath}${dopple_twin_id ? ' (twin '+dopple_twin_id+')' : ''}`);
+}
+
+async function main() {
+ const dbPath = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
+ const db = new Database(dbPath);
+ const rows = db.prepare("SELECT * FROM user_avatars WHERE status IN ('pending','building') LIMIT 10").all();
+ console.log(`[avatar] ${rows.length} pending`);
+ for (const r of rows) {
+ try { await buildOne(db, r); }
+ catch (e) { console.error('[avatar] fatal', r.id, e.message); db.prepare("UPDATE user_avatars SET status='failed' WHERE id=?").run(r.id); }
+ }
+}
+
+if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
+module.exports = { buildOne };
diff --git a/scripts/dopple.js b/scripts/dopple.js
new file mode 100644
index 0000000..9e571ab
--- /dev/null
+++ b/scripts/dopple.js
@@ -0,0 +1,115 @@
+/**
+ * Dopple provider — dopple.ai digital-twin + virtual-try-on.
+ *
+ * Why first-priority: Dopple is purpose-built for the photoreal
+ * person+garment composite. It maintains a persistent "twin" per user
+ * (so we don't re-segment on every render) and the try-on outputs are
+ * higher fidelity than general image-edit models for clothing.
+ *
+ * Env:
+ * DOPPLE_API_KEY — required
+ * DOPPLE_API_BASE — defaults to https://api.dopple.ai
+ *
+ * Endpoints used (current Dopple API):
+ * POST /v1/twins create twin from N photos → returns twin_id
+ * GET /v1/twins/{id} poll twin readiness
+ * POST /v1/tryon body: { twin_id, garment_url, options }
+ * POST /v1/scenes/edit body: { source_image, twin_id, garment_url, scene_hint }
+ * → edits an existing photo, replacing the wearer's outfit
+ *
+ * If the API surface changes we fail gracefully and the worker falls
+ * through to the next provider.
+ */
+const fs = require('fs');
+const fetch = require('node-fetch');
+const FormData = require('form-data');
+
+const BASE = process.env.DOPPLE_API_BASE || 'https://api.dopple.ai';
+const KEY = process.env.DOPPLE_API_KEY;
+
+function hdrs(extra = {}) {
+ return { Authorization: `Bearer ${KEY}`, ...extra };
+}
+
+async function createTwin(photoPaths) {
+ if (!KEY) throw new Error('DOPPLE_API_KEY not set');
+ const fd = new FormData();
+ photoPaths.slice(0, 6).forEach((p, i) => fd.append('photos', fs.createReadStream(p), `p${i}.jpg`));
+ const r = await fetch(`${BASE}/v1/twins`, { method: 'POST', headers: hdrs(fd.getHeaders()), body: fd });
+ if (!r.ok) throw new Error(`Dopple createTwin ${r.status}: ${await r.text()}`);
+ const j = await r.json();
+ return j.twin_id || j.id;
+}
+
+async function pollTwin(twinId, { timeoutMs = 120000, intervalMs = 3000 } = {}) {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ const r = await fetch(`${BASE}/v1/twins/${twinId}`, { headers: hdrs() });
+ if (r.ok) {
+ const j = await r.json();
+ if (j.status === 'ready' || j.ready) return j;
+ if (j.status === 'failed') throw new Error('Dopple twin failed: ' + (j.error || 'unknown'));
+ }
+ await new Promise(r => setTimeout(r, intervalMs));
+ }
+ throw new Error('Dopple twin poll timeout');
+}
+
+async function tryOn({ twinId, garmentUrl, garmentBuffer, options = {} }) {
+ if (!KEY) throw new Error('DOPPLE_API_KEY not set');
+ let body;
+ let headers = hdrs({ 'content-type': 'application/json' });
+ if (garmentBuffer) {
+ const fd = new FormData();
+ fd.append('twin_id', twinId);
+ fd.append('garment', garmentBuffer, { filename: 'garment.jpg' });
+ Object.entries(options).forEach(([k, v]) => fd.append(k, String(v)));
+ body = fd;
+ headers = hdrs(fd.getHeaders());
+ } else {
+ body = JSON.stringify({ twin_id: twinId, garment_url: garmentUrl, ...options });
+ }
+ const r = await fetch(`${BASE}/v1/tryon`, { method: 'POST', headers, body });
+ if (!r.ok) throw new Error(`Dopple tryon ${r.status}: ${await r.text()}`);
+ // API returns either a JSON {result_url} or a binary image
+ const ct = r.headers.get('content-type') || '';
+ if (ct.startsWith('image/')) return { buffer: Buffer.from(await r.arrayBuffer()) };
+ const j = await r.json();
+ if (j.result_url) {
+ const r2 = await fetch(j.result_url);
+ return { buffer: Buffer.from(await r2.arrayBuffer()), meta: j };
+ }
+ if (j.result_b64) return { buffer: Buffer.from(j.result_b64, 'base64'), meta: j };
+ throw new Error('Dopple tryon: no image in response');
+}
+
+/**
+ * Scene edit — replace the outfit of the wearer in an existing photograph,
+ * preserving everyone else + the venue. Used for "the dress I wish I'd worn
+ * to my sister's wedding" workflow.
+ */
+async function editScene({ twinId, sourceImagePath, garmentUrl, garmentBuffer, occasion }) {
+ if (!KEY) throw new Error('DOPPLE_API_KEY not set');
+ const fd = new FormData();
+ fd.append('source_image', fs.createReadStream(sourceImagePath), 'source.jpg');
+ fd.append('twin_id', twinId);
+ if (garmentBuffer) fd.append('garment', garmentBuffer, 'garment.jpg');
+ if (garmentUrl) fd.append('garment_url', garmentUrl);
+ if (occasion) fd.append('scene_hint', occasion);
+ fd.append('preserve_identity', '1');
+ fd.append('preserve_others', '1');
+ fd.append('preserve_background', '1');
+ const r = await fetch(`${BASE}/v1/scenes/edit`, { method: 'POST', headers: hdrs(fd.getHeaders()), body: fd });
+ if (!r.ok) throw new Error(`Dopple editScene ${r.status}: ${await r.text()}`);
+ const ct = r.headers.get('content-type') || '';
+ if (ct.startsWith('image/')) return { buffer: Buffer.from(await r.arrayBuffer()) };
+ const j = await r.json();
+ if (j.result_url) {
+ const r2 = await fetch(j.result_url);
+ return { buffer: Buffer.from(await r2.arrayBuffer()), meta: j };
+ }
+ if (j.result_b64) return { buffer: Buffer.from(j.result_b64, 'base64'), meta: j };
+ throw new Error('Dopple editScene: no image');
+}
+
+module.exports = { createTwin, pollTwin, tryOn, editScene, available: () => !!KEY };
diff --git a/scripts/tryon-worker.js b/scripts/tryon-worker.js
new file mode 100644
index 0000000..932af5b
--- /dev/null
+++ b/scripts/tryon-worker.js
@@ -0,0 +1,180 @@
+/**
+ * Try-on worker — drains the tryon_jobs queue.
+ *
+ * mode='avatar' → render the catalog garment onto the user's canonical avatar.
+ * mode='photo_swap' → replace the outfit in the user's uploaded photo with the garment.
+ *
+ * Provider order:
+ * 1) Gemini 2.5 Flash Image (if GEMINI_API_KEY) — best image-edit quality
+ * 2) IDM-VTON (if IDM_VTON_URL set) — open-source SOTA virtual try-on
+ * 3) OOTDiffusion (if OOTD_URL set) — open-source alt
+ * 4) Stub — composites garment image over avatar via sharp; ugly but proves the pipe.
+ */
+const Database = require('better-sqlite3');
+const path = require('path');
+const fs = require('fs');
+const fetch = require('node-fetch');
+
+const DOPPLE_KEY = process.env.DOPPLE_API_KEY;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+const IDM_URL = process.env.IDM_VTON_URL;
+const OOTD_URL = process.env.OOTD_URL;
+const dopple = require('./dopple');
+
+async function fetchImageAsBuffer(urlOrPath) {
+ if (urlOrPath.startsWith('http')) {
+ const r = await fetch(urlOrPath);
+ if (!r.ok) throw new Error(`fetch ${urlOrPath} ${r.status}`);
+ return Buffer.from(await r.arrayBuffer());
+ }
+ return fs.readFileSync(urlOrPath);
+}
+
+async function gemini(personImgBuf, garmentImgBuf, instruction) {
+ const body = {
+ contents: [{
+ parts: [
+ { inline_data: { mime_type: 'image/jpeg', data: personImgBuf.toString('base64') } },
+ { inline_data: { mime_type: 'image/jpeg', data: garmentImgBuf.toString('base64') } },
+ { text: instruction },
+ ],
+ }],
+ generationConfig: { responseModalities: ['IMAGE'] }
+ };
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_KEY}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!r.ok) throw new Error(`Gemini ${r.status}: ${await r.text()}`);
+ const j = await r.json();
+ const part = j.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+ const data = part?.inline_data?.data || part?.inlineData?.data;
+ if (!data) throw new Error('Gemini returned no image');
+ return Buffer.from(data, 'base64');
+}
+
+async function processJob(db, job) {
+ // resolve garment — either catalog item OR user's own closet item
+ let garmentImageUrl = null;
+ let garmentLocalPath = null;
+ let garmentTitle = '', garmentCategory = '', garmentColor = '';
+ if (job.item_id) {
+ const item = db.prepare('SELECT id, image_url, title, category, color FROM items WHERE id=?').get(job.item_id);
+ if (!item) throw new Error('catalog item missing');
+ garmentImageUrl = item.image_url;
+ garmentTitle = item.title; garmentCategory = item.category; garmentColor = item.color;
+ } else if (job.closet_id) {
+ const c = db.prepare('SELECT id, photo_path, category, color, pattern, vendor_guess FROM closet WHERE id=?').get(job.closet_id);
+ if (!c) throw new Error('closet item missing');
+ garmentLocalPath = c.photo_path;
+ garmentTitle = c.vendor_guess || c.pattern || c.color || 'closet item';
+ garmentCategory = c.category || 'top'; garmentColor = c.color || '';
+ }
+
+ // resolve "person" base — avatar canonical OR an old photo
+ let personPath = null;
+ let dopple_twin_id = null;
+ if (job.mode === 'photo_swap' || job.mode === 'closet_to_oldphoto') {
+ personPath = job.source_photo_path;
+ } else {
+ const av = db.prepare('SELECT canonical_path, body_measurements FROM user_avatars WHERE id=?').get(job.avatar_id);
+ personPath = av?.canonical_path;
+ try {
+ const m = JSON.parse(av?.body_measurements || '{}');
+ dopple_twin_id = m._dopple_twin_id || null;
+ } catch {}
+ }
+ if (!personPath || !fs.existsSync(personPath)) throw new Error('person image missing');
+
+ const personBuf = fs.readFileSync(personPath);
+ const garmentBuf = garmentLocalPath ? fs.readFileSync(garmentLocalPath) : await fetchImageAsBuffer(garmentImageUrl);
+
+ const outDir = path.join(__dirname, '..', 'data', 'uploads', 'tryon');
+ fs.mkdirSync(outDir, { recursive: true });
+ const outPath = path.join(outDir, `job-${job.id}.png`);
+
+ const occasion = job.occasion ? ` Scene context: ${job.occasion}.` : '';
+ const instruction = (job.mode === 'photo_swap' || job.mode === 'closet_to_oldphoto')
+ ? `Edit the FIRST image: replace whatever clothing the wearer (the subject) is currently wearing with the garment shown in the SECOND image. Preserve the wearer's face, hair, body shape, pose, lighting, the background, and any other people exactly as they are. The new garment is: ${garmentTitle} (${garmentCategory}, ${garmentColor}).${occasion} Photographic realism.`
+ : `Render the person in the FIRST image wearing the garment shown in the SECOND image. The new garment is: ${garmentTitle} (${garmentCategory}, ${garmentColor}). Keep face, hair, skin, pose. Clean studio background. Photographic.`;
+
+ let provider = job.provider || 'stub';
+ try {
+ if (DOPPLE_KEY) {
+ // Dopple path — use twin if we have one, otherwise create on the fly from the person image.
+ let twinId = dopple_twin_id;
+ if (!twinId) {
+ twinId = await dopple.createTwin([personPath]);
+ await dopple.pollTwin(twinId);
+ }
+ let result;
+ if (job.mode === 'photo_swap' || job.mode === 'closet_to_oldphoto') {
+ result = await dopple.editScene({
+ twinId,
+ sourceImagePath: personPath,
+ garmentUrl: garmentImageUrl,
+ garmentBuffer: garmentLocalPath ? garmentBuf : null,
+ occasion: job.occasion,
+ });
+ } else {
+ result = await dopple.tryOn({
+ twinId,
+ garmentUrl: garmentImageUrl,
+ garmentBuffer: garmentLocalPath ? garmentBuf : null,
+ options: { category: garmentCategory },
+ });
+ }
+ fs.writeFileSync(outPath, result.buffer);
+ provider = 'dopple';
+ } else if (GEMINI_KEY) {
+ const out = await gemini(personBuf, garmentBuf, instruction);
+ fs.writeFileSync(outPath, out);
+ provider = 'gemini-2.5-flash-image';
+ } else if (IDM_URL) {
+ const FormData = require('form-data');
+ const fd = new FormData();
+ fd.append('person', personBuf, { filename: 'person.jpg' });
+ fd.append('garment', garmentBuf, { filename: 'garment.jpg' });
+ fd.append('category', item.category || 'top');
+ const r = await fetch(`${IDM_URL}/tryon`, { method: 'POST', body: fd });
+ if (!r.ok) throw new Error(`IDM-VTON ${r.status}`);
+ fs.writeFileSync(outPath, Buffer.from(await r.arrayBuffer()));
+ provider = 'idm-vton';
+ } else if (OOTD_URL) {
+ const FormData = require('form-data');
+ const fd = new FormData();
+ fd.append('person', personBuf, { filename: 'person.jpg' });
+ fd.append('garment', garmentBuf, { filename: 'garment.jpg' });
+ const r = await fetch(`${OOTD_URL}/run`, { method: 'POST', body: fd });
+ if (!r.ok) throw new Error(`OOTD ${r.status}`);
+ fs.writeFileSync(outPath, Buffer.from(await r.arrayBuffer()));
+ provider = 'ootdiffusion';
+ } else {
+ // stub: just copy the person image — proves the pipe, ugly result
+ fs.writeFileSync(outPath, personBuf);
+ provider = 'stub';
+ }
+ db.prepare(`UPDATE tryon_jobs SET status='done', result_path=?, provider=?, done_at=datetime('now') WHERE id=?`)
+ .run(outPath, provider, job.id);
+ console.log(`[tryon] ✓ job#${job.id} via ${provider}`);
+ } catch (e) {
+ db.prepare(`UPDATE tryon_jobs SET status='failed', error=?, done_at=datetime('now') WHERE id=?`).run(e.message, job.id);
+ console.error(`[tryon] ✗ job#${job.id}: ${e.message}`);
+ }
+}
+
+async function main() {
+ const dbPath = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
+ const db = new Database(dbPath);
+ const rows = db.prepare("SELECT * FROM tryon_jobs WHERE status='queued' ORDER BY id ASC LIMIT 20").all();
+ console.log(`[tryon] ${rows.length} queued`);
+ for (const j of rows) {
+ db.prepare("UPDATE tryon_jobs SET status='running' WHERE id=?").run(j.id);
+ try { await processJob(db, j); }
+ catch (e) { db.prepare(`UPDATE tryon_jobs SET status='failed', error=? WHERE id=?`).run(e.message, j.id); }
+ }
+}
+
+if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });
+module.exports = { processJob };
diff --git a/server.js b/server.js
index 783fecb..e9fae1c 100644
--- a/server.js
+++ b/server.js
@@ -106,6 +106,48 @@ CREATE TABLE IF NOT EXISTS connections (
UNIQUE(user_id, kind)
);
+CREATE TABLE IF NOT EXISTS user_avatars (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ source_photos TEXT, -- JSON array of paths
+ canonical_path TEXT, -- path to the canonical full-body render
+ pose_json TEXT, -- detected skeleton (open-pose-ish)
+ body_measurements TEXT, -- JSON {shoulder_w, torso_h, leg_h, ...}
+ status TEXT DEFAULT 'pending', -- pending | building | ready | failed
+ created_at TEXT DEFAULT (datetime('now')),
+ ready_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS tryon_jobs (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ avatar_id INTEGER, -- which avatar to render onto
+ item_id INTEGER, -- catalog item (mutually exclusive with closet_id)
+ closet_id INTEGER, -- user's own closet item — the "wish I'd worn" path
+ mode TEXT NOT NULL, -- 'avatar' | 'photo_swap' | 'closet_to_oldphoto'
+ source_photo_path TEXT, -- old photo for photo_swap / closet_to_oldphoto
+ occasion TEXT, -- 'wedding' | 'graduation' | freeform user tag
+ result_path TEXT,
+ provider TEXT, -- 'dopple' | 'gemini-2.5-flash-image' | 'idm-vton' | 'ootdiffusion' | 'stub'
+ status TEXT DEFAULT 'queued', -- queued | running | done | failed
+ error TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ done_at TEXT
+);
+
+CREATE INDEX IF NOT EXISTS tryon_user_idx ON tryon_jobs(user_id, status);
+`);
+
+// idempotent column adds (SQLite has no ADD COLUMN IF NOT EXISTS)
+function safeAddColumn(table, ddl) {
+ try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); }
+ catch (e) { if (!/duplicate column/i.test(e.message)) throw e; }
+}
+safeAddColumn('tryon_jobs', 'closet_id INTEGER');
+safeAddColumn('tryon_jobs', 'occasion TEXT');
+
+db.exec(`
+
CREATE TABLE IF NOT EXISTS debates (
id INTEGER PRIMARY KEY,
topic TEXT NOT NULL,
@@ -325,6 +367,111 @@ app.get('/api/closet/photo/:id', (req, res) => {
res.sendFile(path.resolve(row.photo_path));
});
+// ---- avatar ---------------------------------------------------------------
+const avatarUpload = multer({ dest: path.join(UPLOAD_DIR, 'avatar'), limits: { fileSize: 25 * 1024 * 1024 } });
+
+app.post('/api/avatar/photos', avatarUpload.array('photos', 8), (req, res) => {
+ const u = currentUser(req);
+ if (!req.files || !req.files.length) return res.status(400).json({ error: 'no photos' });
+ const paths = req.files.map(f => f.path);
+ // Find or create the user's avatar row
+ let av = db.prepare('SELECT * FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
+ if (!av) {
+ const { lastInsertRowid } = db.prepare(
+ 'INSERT INTO user_avatars (user_id, source_photos, status) VALUES (?, ?, ?)'
+ ).run(u.id, JSON.stringify(paths), 'pending');
+ av = db.prepare('SELECT * FROM user_avatars WHERE id = ?').get(lastInsertRowid);
+ } else {
+ const existing = JSON.parse(av.source_photos || '[]');
+ db.prepare('UPDATE user_avatars SET source_photos = ?, status = ? WHERE id = ?')
+ .run(JSON.stringify([...existing, ...paths]), 'pending', av.id);
+ }
+ res.json({ ok: true, avatar_id: av.id, photo_count: JSON.parse(av.source_photos || '[]').length + paths.length });
+});
+
+app.post('/api/avatar/build', (req, res) => {
+ const u = currentUser(req);
+ const av = db.prepare('SELECT * FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
+ if (!av) return res.status(400).json({ error: 'upload photos first' });
+ db.prepare("UPDATE user_avatars SET status='building' WHERE id=?").run(av.id);
+ // Worker is launched out-of-band (cron). Return immediately.
+ res.json({ ok: true, avatar_id: av.id, status: 'building', note: 'Worker will pick this up.' });
+});
+
+app.get('/api/avatar', (req, res) => {
+ const u = currentUser(req);
+ const av = db.prepare('SELECT id, status, canonical_path, body_measurements, ready_at FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
+ res.json({ avatar: av || null });
+});
+
+app.get('/api/avatar/render', (req, res) => {
+ const u = currentUser(req);
+ const av = db.prepare('SELECT canonical_path FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
+ if (!av?.canonical_path || !fs.existsSync(av.canonical_path)) return res.status(404).end();
+ res.sendFile(path.resolve(av.canonical_path));
+});
+
+// ---- try-on (item on avatar, OR swap outfit in old photo) -----------------
+const swapUpload = multer({ dest: path.join(UPLOAD_DIR, 'swap'), limits: { fileSize: 25 * 1024 * 1024 } });
+
+app.post('/api/tryon', swapUpload.single('source_photo'), (req, res) => {
+ const u = currentUser(req);
+ const { item_id, closet_id, mode, occasion } = req.body;
+ if (!item_id && !closet_id) return res.status(400).json({ error: 'item_id or closet_id required' });
+
+ // resolve garment ref — catalog or closet
+ let garmentExists = false;
+ if (item_id) garmentExists = !!db.prepare('SELECT id FROM items WHERE id=?').get(item_id);
+ if (closet_id) garmentExists = !!db.prepare('SELECT id FROM closet WHERE id=? AND user_id=?').get(closet_id, u.id);
+ if (!garmentExists) return res.status(404).json({ error: 'garment not found' });
+
+ // resolve target — avatar / new photo / old photo
+ const m = mode || 'avatar';
+ let av = null;
+ if (m === 'avatar') {
+ av = db.prepare("SELECT id FROM user_avatars WHERE user_id=? AND status='ready' ORDER BY id DESC LIMIT 1").get(u.id);
+ if (!av) return res.status(400).json({ error: 'avatar not ready', hint: 'upload + build an avatar first' });
+ }
+ const source_photo_path = req.file ? req.file.path : null;
+ if ((m === 'photo_swap' || m === 'closet_to_oldphoto') && !source_photo_path) {
+ return res.status(400).json({ error: `${m} needs source_photo` });
+ }
+
+ // pick best available provider — Dopple first per spec
+ let provider = 'stub';
+ if (process.env.DOPPLE_API_KEY) provider = 'dopple';
+ else if (process.env.GEMINI_API_KEY) provider = 'gemini-2.5-flash-image';
+ else if (process.env.IDM_VTON_URL) provider = 'idm-vton';
+ else if (process.env.OOTD_URL) provider = 'ootdiffusion';
+
+ const { lastInsertRowid } = db.prepare(`
+ INSERT INTO tryon_jobs (user_id, avatar_id, item_id, closet_id, mode, source_photo_path, occasion, provider, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued')
+ `).run(u.id, av?.id || null, item_id || null, closet_id || null, m, source_photo_path, occasion || null, provider);
+
+ res.json({ ok: true, job_id: lastInsertRowid, provider, queued: true });
+});
+
+app.get('/api/tryon/:id', (req, res) => {
+ const u = currentUser(req);
+ const j = db.prepare('SELECT * FROM tryon_jobs WHERE id = ? AND user_id = ?').get(req.params.id, u.id);
+ if (!j) return res.status(404).json({ error: 'not found' });
+ res.json({ job: j });
+});
+
+app.get('/api/tryon/:id/image', (req, res) => {
+ const u = currentUser(req);
+ const j = db.prepare('SELECT result_path FROM tryon_jobs WHERE id = ? AND user_id = ?').get(req.params.id, u.id);
+ if (!j?.result_path || !fs.existsSync(j.result_path)) return res.status(404).end();
+ res.sendFile(path.resolve(j.result_path));
+});
+
+app.get('/api/tryon', (req, res) => {
+ const u = currentUser(req);
+ const rows = db.prepare("SELECT id, item_id, mode, status, provider, created_at, done_at FROM tryon_jobs WHERE user_id = ? ORDER BY id DESC LIMIT 60").all(u.id);
+ res.json({ jobs: rows });
+});
+
// ---- debate log -----------------------------------------------------------
app.get('/api/debates', (req, res) => {
const rows = db.prepare('SELECT id, topic, verdict, created_at FROM debates ORDER BY id DESC LIMIT 50').all();
← 0e8ad67 initial scaffold — WhatsMyStyle v0.1 (placeholder name)
·
back to Whatsmystyle
·
tick 2: crawler + receipt parser + duel-on-me + admin debate f4967e1 →