[object Object]

← 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

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 →