← back to Whatsmystyle
feat: /studio — photo-first, sectioned swap, per-photo history
20e99b7bdc9ab867bea70c881b8308d1b32085c1 · 2026-05-11 23:58:28 -0700 · Steve Abrams
3-column wardrobe studio:
LEFT — picker of your uploaded photos (avatar sources + closet)
MIDDLE — current source + live result + history strip of every render
done on this photo (your wardrobe archive grows per-photo)
RIGHT — 6 category buttons (top / outerwear / bottom / dress / shoes
/ bag). Click a category → catalog filtered to that category
→ click a piece → swap. Provider chain (Dopple→FASHN→OpenAI→
Gemini→stub) decides what renders.
Endpoints:
GET /api/studio/photos list user's source photos
GET /api/studio/photo?path=... stream a staged photo (gated)
GET /api/studio/history?source_photo=... timeline per photo
POST /api/studio/swap { source_photo, item_id, category }
queue a photo_swap render
3-for-3 successful FASHN renders this session (identity + pose + scene
preserved each time):
job-8 Loro Piana cashmere crewneck 8.0¢
job-10 Burberry trench coat (camel) 8.0¢
job-11 Levi's denim jacket 8.0¢ (already done by worker)
Ledger now: $0.238 spent / $4.76 remaining of $5 budget.
E2E remains 26/26 green post-edits.
Files touched
M data/waitlist.csvM data/whatsmystyle.db-shmM data/whatsmystyle.db-walM public/css/app.cssM public/index.htmlM public/js/app.jsM server.js
Diff
commit 20e99b7bdc9ab867bea70c881b8308d1b32085c1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 23:58:28 2026 -0700
feat: /studio — photo-first, sectioned swap, per-photo history
3-column wardrobe studio:
LEFT — picker of your uploaded photos (avatar sources + closet)
MIDDLE — current source + live result + history strip of every render
done on this photo (your wardrobe archive grows per-photo)
RIGHT — 6 category buttons (top / outerwear / bottom / dress / shoes
/ bag). Click a category → catalog filtered to that category
→ click a piece → swap. Provider chain (Dopple→FASHN→OpenAI→
Gemini→stub) decides what renders.
Endpoints:
GET /api/studio/photos list user's source photos
GET /api/studio/photo?path=... stream a staged photo (gated)
GET /api/studio/history?source_photo=... timeline per photo
POST /api/studio/swap { source_photo, item_id, category }
queue a photo_swap render
3-for-3 successful FASHN renders this session (identity + pose + scene
preserved each time):
job-8 Loro Piana cashmere crewneck 8.0¢
job-10 Burberry trench coat (camel) 8.0¢
job-11 Levi's denim jacket 8.0¢ (already done by worker)
Ledger now: $0.238 spent / $4.76 remaining of $5 budget.
E2E remains 26/26 green post-edits.
---
data/waitlist.csv | 1 +
data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
data/whatsmystyle.db-wal | Bin 3316632 -> 3617392 bytes
public/css/app.css | 27 +++++++++++++
public/index.html | 38 ++++++++++++++++++
public/js/app.js | 101 +++++++++++++++++++++++++++++++++++++++++++++++
server.js | 66 +++++++++++++++++++++++++++++++
7 files changed, 233 insertions(+)
diff --git a/data/waitlist.csv b/data/waitlist.csv
index 9866ee5..352869f 100644
--- a/data/waitlist.csv
+++ b/data/waitlist.csv
@@ -13,3 +13,4 @@ e2e-1778562484174@example.com,e2e,2026-05-12T05:08:04.183Z
e2e-1778563130826@example.com,e2e,2026-05-12T05:18:50.826Z
e2e-1778564947982@example.com,e2e,2026-05-12T05:49:07.984Z
e2e-1778566724793@example.com,e2e,2026-05-12T06:18:44.794Z
+e2e-1778569083894@example.com,e2e,2026-05-12T06:58:03.895Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index cc4d9ac..c508901 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 6b1af78..df4d966 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/css/app.css b/public/css/app.css
index b0b5c5b..2d58914 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -188,6 +188,33 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
.pitch-waitlist input { padding: 14px 18px; border: 1.5px solid var(--ink); border-radius: 999px; font-size: 16px; min-width: 260px; }
.pitch-waitlist button { padding: 14px 24px; border: 0; border-radius: 999px; background: var(--ink); color: var(--paper); font-size: 16px; font-weight: 600; cursor: pointer; }
+/* Studio — photo-centric 3-column layout */
+.studio-layout { display: grid; grid-template-columns: 200px 1fr 260px; gap: 18px; margin-top: 18px; }
+@media (max-width: 980px) { .studio-layout { grid-template-columns: 1fr; } }
+.studio-left, .studio-right { padding: 14px; background: var(--paper); border: 1px solid var(--line); border-radius: 18px; }
+.studio-left h3, .studio-right h3, .studio-mid h3 { font-family: var(--font-display); font-size: 18px; font-weight: 600; margin: 0 0 10px; }
+.studio-photo-list { display: grid; gap: 8px; }
+.studio-photo-list button { padding: 0; border: 2px solid transparent; border-radius: 10px; background: var(--paper-2); cursor: pointer; overflow: hidden; aspect-ratio: 3/4; }
+.studio-photo-list button.sel { border-color: var(--accent); }
+.studio-photo-list img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.studio-current { aspect-ratio: 3/4; background: var(--paper-2); border-radius: 12px; overflow: hidden; }
+.studio-current img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.studio-mid { padding: 14px; background: var(--paper); border: 1px solid var(--line); border-radius: 18px; }
+.studio-result { min-height: 360px; display: grid; place-items: center; background: var(--paper-2); border-radius: 14px; padding: 14px; }
+.studio-result img { max-width: 100%; max-height: 540px; border-radius: 10px; }
+.studio-sections { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.section-btn { padding: 10px 12px; background: var(--paper); border: 1.5px solid var(--ink); border-radius: 999px; font-size: 13px; cursor: pointer; }
+.section-btn.active { background: var(--ink); color: var(--paper); }
+.section-btn:hover { transform: translateY(-1px); }
+.studio-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-height: 480px; overflow-y: auto; padding-top: 6px; }
+.studio-picker button { padding: 0; background: var(--paper-2); border: 1px solid var(--line); border-radius: 10px; cursor: pointer; overflow: hidden; }
+.studio-picker img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
+.studio-picker .label { padding: 4px 6px; font-size: 11px; text-align: center; }
+.studio-history-strip { display: flex; gap: 8px; overflow-x: auto; padding: 4px 0 12px; scroll-snap-type: x mandatory; }
+.studio-history-strip .h-tile { flex: 0 0 140px; aspect-ratio: 3/4; background: var(--paper-2); border-radius: 10px; overflow: hidden; scroll-snap-align: start; position: relative; }
+.studio-history-strip .h-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.studio-history-strip .h-tile .h-cat { position: absolute; bottom: 4px; left: 4px; right: 4px; background: rgba(0,0,0,.65); color: var(--paper); padding: 3px 6px; border-radius: 6px; font-size: 10px; text-align: center; }
+
/* live puppet (Adobe Character Animator pass-through) */
.live-wrap { display: grid; gap: 14px; }
.live-wrap video { width: 100%; max-width: 720px; aspect-ratio: 16/9; background: #000; border-radius: 18px; transform: scaleX(-1); }
diff --git a/public/index.html b/public/index.html
index b455617..f0e0fca 100644
--- a/public/index.html
+++ b/public/index.html
@@ -26,6 +26,7 @@
<a href="#" data-screen="timetravel">Time-Travel Wardrobe</a>
<a href="#" data-screen="tt-demo">Time-Travel Demo</a>
<a href="#" data-screen="taste">My Taste</a>
+ <a href="#" data-screen="studio">Studio</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>
@@ -182,6 +183,43 @@
</div>
</section>
+ <!-- Studio — photo on left, sectioned wardrobe panel on right, history below -->
+ <section id="studio" class="screen" hidden>
+ <h2>Studio</h2>
+ <p class="muted">Pick a photo of you. Pick a clothing section on the right. Pick a piece. We re-dress you. Every render lands in your wardrobe history below this photo — your own try-on archive.</p>
+ <div class="studio-layout">
+ <aside class="studio-left">
+ <h3>Your photos</h3>
+ <div id="studio-photos" class="studio-photo-list"></div>
+ <h3 style="margin-top: 22px;">Current source</h3>
+ <div id="studio-current" class="studio-current"></div>
+ </aside>
+ <main class="studio-mid">
+ <h3>Result</h3>
+ <div id="studio-result" class="studio-result">
+ <p class="muted">Pick a photo on the left, then a category on the right.</p>
+ </div>
+ <h3 style="margin-top: 22px;">Wardrobe history on this photo</h3>
+ <div id="studio-history" class="studio-history-strip"></div>
+ </main>
+ <aside class="studio-right">
+ <h3>Swap by section</h3>
+ <div class="studio-sections">
+ <button class="section-btn" data-category="top">Top / Shirt</button>
+ <button class="section-btn" data-category="outerwear">Coat / Jacket</button>
+ <button class="section-btn" data-category="bottom">Pants / Skirt</button>
+ <button class="section-btn" data-category="dress">Dress</button>
+ <button class="section-btn" data-category="shoes">Shoes</button>
+ <button class="section-btn" data-category="bag">Bag</button>
+ </div>
+ <h3 style="margin-top: 18px;">Pick a piece</h3>
+ <div id="studio-picker" class="studio-picker">
+ <p class="muted">Choose a category first.</p>
+ </div>
+ </aside>
+ </div>
+ </section>
+
<!-- Live puppet — Adobe Character Animator pass-through -->
<section id="live" class="screen" hidden>
<h2>Live as your animated self</h2>
diff --git a/public/js/app.js b/public/js/app.js
index 1092aec..2c3d84d 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -662,6 +662,106 @@ $('#live-stop').addEventListener('click', () => live.stop());
$('#live-overlay-toggle').addEventListener('click', () => live.toggleOverlay());
$('#live-snap').addEventListener('click', () => live.snap());
+// ---------- Studio ---------------------------------------------------------
+// LEFT pane: pick a photo of yourself.
+// MIDDLE pane: live render + history-strip below.
+// RIGHT pane: pick a clothing section, then a catalog piece in that category.
+const studio = {
+ currentSource: null, // server-side path of selected photo
+ currentSourceUrl: null, // proxied URL we can show in <img>
+ category: 'top',
+ poll: null,
+
+ async loadPhotos() {
+ const r = await fetch('/api/studio/photos').then(r => r.json());
+ const list = $('#studio-photos');
+ list.innerHTML = '';
+ if (!r.photos?.length) { list.innerHTML = '<p class="muted">Upload photos in My Avatar first.</p>'; return; }
+ r.photos.forEach(p => {
+ const b = document.createElement('button');
+ b.title = p.path.split('/').pop();
+ b.innerHTML = `<img src="${p.url}" alt="" loading="lazy">`;
+ b.onclick = () => { studio.selectPhoto(p); list.querySelectorAll('button').forEach(x => x.classList.remove('sel')); b.classList.add('sel'); };
+ list.appendChild(b);
+ });
+ // auto-select first
+ if (r.photos.length && !studio.currentSource) {
+ studio.selectPhoto(r.photos[0]);
+ list.querySelector('button')?.classList.add('sel');
+ }
+ },
+ selectPhoto(p) {
+ studio.currentSource = p.path;
+ studio.currentSourceUrl = p.url;
+ $('#studio-current').innerHTML = `<img src="${p.url}" alt="">`;
+ $('#studio-result').innerHTML = '<p class="muted">Pick a category on the right, then a piece.</p>';
+ studio.loadHistory();
+ },
+ setCategory(cat) {
+ studio.category = cat;
+ $$('.section-btn').forEach(b => b.classList.toggle('active', b.dataset.category === cat));
+ studio.loadPicker();
+ },
+ async loadPicker() {
+ const picker = $('#studio-picker');
+ picker.innerHTML = '<p class="muted">Loading…</p>';
+ // Pull from /api/recommend filtered by category
+ const r = await fetch('/api/recommend?limit=60').then(r => r.json());
+ const items = (r.items || []).filter(it => it.category === studio.category);
+ if (!items.length) { picker.innerHTML = `<p class="muted">No ${studio.category} items in catalog yet.</p>`; return; }
+ picker.innerHTML = '';
+ items.forEach(it => {
+ const b = document.createElement('button');
+ b.title = `${it.brand || ''} · ${it.title}`;
+ b.innerHTML = `<img src="${it.image_url}" alt="" loading="lazy"><div class="label">${it.brand || it.title.slice(0, 18)}</div>`;
+ b.onclick = () => studio.swap(it);
+ picker.appendChild(b);
+ });
+ },
+ async swap(item) {
+ if (!studio.currentSource) { alert('Pick a photo on the left first.'); return; }
+ $('#studio-result').innerHTML = `<div><p>Rendering you in <strong>${item.brand || item.title}</strong>…</p><p class="muted">~30-60s via FASHN</p></div>`;
+ const r = await fetch('/api/studio/swap', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ source_photo: studio.currentSource, item_id: item.id, category: studio.category }),
+ }).then(r => r.json());
+ if (r.error) { $('#studio-result').innerHTML = `<p class="muted">Error: ${r.error}</p>`; return; }
+ studio.pollResult(r.job_id);
+ },
+ async pollResult(jobId) {
+ clearInterval(studio.poll);
+ studio.poll = setInterval(async () => {
+ const j = await fetch(`/api/tryon/${jobId}`).then(r => r.json());
+ if (j.job?.status === 'done') {
+ clearInterval(studio.poll);
+ $('#studio-result').innerHTML = `<img src="/api/tryon/${jobId}/image?t=${Date.now()}" alt="result">`;
+ studio.loadHistory();
+ } else if (j.job?.status === 'failed') {
+ clearInterval(studio.poll);
+ $('#studio-result').innerHTML = `<p class="muted">Render failed: ${j.job.error || 'unknown'}</p>`;
+ }
+ }, 2500);
+ },
+ async loadHistory() {
+ const strip = $('#studio-history');
+ if (!studio.currentSource) { strip.innerHTML = ''; return; }
+ const r = await fetch('/api/studio/history?source_photo=' + encodeURIComponent(studio.currentSource)).then(r => r.json());
+ strip.innerHTML = '';
+ const done = (r.history || []).filter(h => h.status === 'done');
+ if (!done.length) { strip.innerHTML = '<p class="muted">No renders on this photo yet — pick a piece on the right.</p>'; return; }
+ done.forEach(h => {
+ const t = document.createElement('div');
+ t.className = 'h-tile';
+ t.title = `${h.brand || ''} ${h.title || ''}`;
+ t.innerHTML = `<img src="/api/tryon/${h.id}/image" alt="" loading="lazy"><div class="h-cat">${h.category || h.mode}</div>`;
+ t.onclick = () => { $('#studio-result').innerHTML = `<img src="/api/tryon/${h.id}/image" alt="">`; };
+ strip.appendChild(t);
+ });
+ },
+};
+$$('.section-btn').forEach(b => b.addEventListener('click', () => studio.setCategory(b.dataset.category)));
+
// ---------- Router ---------------------------------------------------------
async function loadTasteTimeline() {
const r = await fetch('/api/me/taste/history').then(r => r.json());
@@ -774,6 +874,7 @@ function route(name) {
if (name === 'avatar') pollAvatar();
if (name === 'timetravel') loadTimeTravel();
if (name === 'tryons') loadTryons();
+ if (name === 'studio') { studio.loadPhotos(); studio.setCategory(studio.category); }
if (name === 'live') live.populate();
if (name === 'taste') loadTaste();
if (name === 'support') loadSupport();
diff --git a/server.js b/server.js
index 9e3743e..6ba3450 100644
--- a/server.js
+++ b/server.js
@@ -761,6 +761,72 @@ app.post('/api/waitlist', (req, res) => {
res.json({ ok: true });
});
+// ---- studio: per-photo render history -----------------------------------
+// Studio mode is: pick a source photo of yourself, then sectioned right-panel
+// lets you swap each clothing category. Every render is preserved as a row in
+// the tryon_jobs table keyed by source_photo_path — so the user builds a
+// wardrobe history attached to that photo.
+app.get('/api/studio/history', (req, res) => {
+ const u = currentUser(req);
+ const source = req.query.source_photo;
+ const rows = source
+ ? db.prepare(`SELECT t.id, t.item_id, t.closet_id, t.mode, t.status, t.created_at, t.done_at,
+ i.title, i.brand, i.category
+ FROM tryon_jobs t LEFT JOIN items i ON i.id = t.item_id
+ WHERE t.user_id = ? AND t.source_photo_path = ?
+ ORDER BY t.id DESC`).all(u.id, source)
+ : db.prepare(`SELECT DISTINCT t.source_photo_path, COUNT(*) over (PARTITION BY t.source_photo_path) AS n_renders
+ FROM tryon_jobs t
+ WHERE t.user_id = ? AND t.source_photo_path IS NOT NULL`).all(u.id);
+ res.json({ source, history: rows });
+});
+
+// Studio render — same as /api/tryon but ALWAYS uses photo_swap mode against
+// the chosen source photo, with category coming from the catalog item.
+app.post('/api/studio/swap', express.json({ limit: '256kb' }), (req, res) => {
+ const u = currentUser(req);
+ const { source_photo, item_id, category } = req.body || {};
+ if (!source_photo || !item_id) return res.status(400).json({ error: 'source_photo + item_id required' });
+ if (process.env.GEMINI_API_KEY || process.env.FASHN_API_KEY || process.env.DOPPLE_API_KEY) {
+ if (spentBy(u.id) >= BUDGET_CENTS_PER_USER) {
+ return res.status(402).json({ error: 'budget exhausted', spent_cents: spentBy(u.id), budget_cents: BUDGET_CENTS_PER_USER });
+ }
+ }
+ const provider = process.env.DOPPLE_API_KEY ? 'dopple'
+ : process.env.FASHN_API_KEY ? 'fashn-ai'
+ : process.env.OPENAI_API_KEY ? 'gpt-image-1'
+ : process.env.GEMINI_API_KEY ? 'gemini-2.5-flash-image' : 'stub';
+ const { lastInsertRowid } = db.prepare(`
+ INSERT INTO tryon_jobs (user_id, item_id, mode, source_photo_path, provider, status, occasion)
+ VALUES (?, ?, 'photo_swap', ?, ?, 'queued', ?)
+ `).run(u.id, item_id, source_photo, provider, category || null);
+ res.json({ ok: true, job_id: lastInsertRowid, provider });
+});
+
+// List user's previously uploaded source photos (for the LEFT pane picker)
+app.get('/api/studio/photos', (req, res) => {
+ const u = currentUser(req);
+ // Avatar source photos + any prior closet uploads — anything we have on disk
+ const av = db.prepare('SELECT source_photos FROM user_avatars WHERE user_id = ? ORDER BY id DESC LIMIT 1').get(u.id);
+ let paths = [];
+ try { paths = JSON.parse(av?.source_photos || '[]'); } catch {}
+ const closet = db.prepare('SELECT id, photo_path FROM closet WHERE user_id = ? AND photo_path IS NOT NULL ORDER BY id DESC').all(u.id);
+ const photos = [
+ ...paths.map(p => ({ kind: 'avatar', path: p, url: '/api/studio/photo?path=' + encodeURIComponent(p) })),
+ ...closet.map(c => ({ kind: 'closet', path: c.photo_path, id: c.id, url: '/api/closet/photo/' + c.id })),
+ ];
+ res.json({ photos });
+});
+
+// Stream a staged photo by path (admin-ish — only paths inside data/uploads/)
+app.get('/api/studio/photo', (req, res) => {
+ const u = currentUser(req);
+ const p = req.query.path;
+ if (!p || !p.startsWith(path.join(__dirname, 'data', 'uploads'))) return res.status(400).end();
+ if (!fs.existsSync(p)) return res.status(404).end();
+ res.sendFile(p);
+});
+
// ---- taste history -------------------------------------------------------
app.get('/api/me/taste/history', (req, res) => {
const u = currentUser(req);
← 21734df WIN: FASHN AI renders Steve photoreally + preserves identity
·
back to Whatsmystyle
·
feat: garment extractor — pull a piece out of an old photo 47423bd →