[object Object]

← back to Wallco Ai

fix /api/room: read fresh local_path from DB; add parallel re-render audit script

78f4fe21cc46abd6a50330a92b541a6901c357df · 2026-05-12 11:52:34 -0700 · SteveStudio2

ROOT CAUSE — Steve's screenshot showed a teal+copper composite pattern
at the top of the studio payoff but the 3 room mockup thumbnails below
showed a completely DIFFERENT pattern (green+plaid). Investigation:

/api/room's source-of-truth for the patternBase64 input to the upstream
room-renderer is the local file at d.local_path, where d comes from
findDesign(design_id). findDesign reads ONLY from the in-memory DESIGNS
snapshot loaded at server startup. After a /api/studio/compose or a
/design/:id/bake, the DB's spoon_all_designs.local_path updates but the
in-memory DESIGNS entry stays stale — so the room renderer received the
OLD pattern file, producing rooms wallpapered with the wrong design.

FIX:
- /api/room now reads local_path live from spoon_all_designs at request
  time, falling back to the in-memory DESIGNS lookup only if the live
  read fails. Guarantees we send the CURRENT pattern to the upstream.
- Existing in-memory cache stays intact for hot paths (gallery render
  etc.) — only the room-render hot path bypasses it.

REPAIR — scripts/rerender-rooms-debug.js:
- Walks every distinct (design_id, room_type) in studio_renders + their
  attached wallco_rooms (or runs canonical bedroom/living/dining if
  --design-id passed for an unrendered design)
- Re-fires /api/room in parallel with capped concurrency (default 3)
- Optional --dry-run to count targets first
- Uses the same psql-shell pattern (socket auth on macOS, sudo-postgres
  on Linux) as the existing server.js psqlQuery helper

Usage to repair a specific design:
  node scripts/rerender-rooms-debug.js --design-id 1234 --parallelism 3

Usage to repair the whole studio_renders history after deploy:
  node scripts/rerender-rooms-debug.js --parallelism 3

Local Mac2 dry-run finds 0 targets (studio_renders empty here — bug is
on Kamatera). Deploy to Kamatera + run the repair script there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 78f4fe21cc46abd6a50330a92b541a6901c357df
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 11:52:34 2026 -0700

    fix /api/room: read fresh local_path from DB; add parallel re-render audit script
    
    ROOT CAUSE — Steve's screenshot showed a teal+copper composite pattern
    at the top of the studio payoff but the 3 room mockup thumbnails below
    showed a completely DIFFERENT pattern (green+plaid). Investigation:
    
    /api/room's source-of-truth for the patternBase64 input to the upstream
    room-renderer is the local file at d.local_path, where d comes from
    findDesign(design_id). findDesign reads ONLY from the in-memory DESIGNS
    snapshot loaded at server startup. After a /api/studio/compose or a
    /design/:id/bake, the DB's spoon_all_designs.local_path updates but the
    in-memory DESIGNS entry stays stale — so the room renderer received the
    OLD pattern file, producing rooms wallpapered with the wrong design.
    
    FIX:
    - /api/room now reads local_path live from spoon_all_designs at request
      time, falling back to the in-memory DESIGNS lookup only if the live
      read fails. Guarantees we send the CURRENT pattern to the upstream.
    - Existing in-memory cache stays intact for hot paths (gallery render
      etc.) — only the room-render hot path bypasses it.
    
    REPAIR — scripts/rerender-rooms-debug.js:
    - Walks every distinct (design_id, room_type) in studio_renders + their
      attached wallco_rooms (or runs canonical bedroom/living/dining if
      --design-id passed for an unrendered design)
    - Re-fires /api/room in parallel with capped concurrency (default 3)
    - Optional --dry-run to count targets first
    - Uses the same psql-shell pattern (socket auth on macOS, sudo-postgres
      on Linux) as the existing server.js psqlQuery helper
    
    Usage to repair a specific design:
      node scripts/rerender-rooms-debug.js --design-id 1234 --parallelism 3
    
    Usage to repair the whole studio_renders history after deploy:
      node scripts/rerender-rooms-debug.js --parallelism 3
    
    Local Mac2 dry-run finds 0 targets (studio_renders empty here — bug is
    on Kamatera). Deploy to Kamatera + run the repair script there.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 scripts/rerender-rooms-debug.js | 100 ++++++++++++++++++++++++++++++++++++++++
 server.js                       |  35 ++++++++++----
 2 files changed, 126 insertions(+), 9 deletions(-)

diff --git a/scripts/rerender-rooms-debug.js b/scripts/rerender-rooms-debug.js
new file mode 100644
index 0000000..39c6ecb
--- /dev/null
+++ b/scripts/rerender-rooms-debug.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+// rerender-rooms-debug.js — parallel re-render of room mockups whose input
+// pattern may have drifted from the design's current local_path.
+//
+// Usage: node scripts/rerender-rooms-debug.js [--design-id N] [--parallelism N] [--dry-run]
+//
+// Bug context: pre-2026-05-12 fix, /api/room read patternBase64 from the
+// in-memory DESIGNS array, which lagged behind spoon_all_designs.local_path
+// updates. Some rooms ended up rendering with the WRONG pattern. This script
+// re-fires /api/room for every studio_render whose attached room was
+// generated before the fix landed (or for a specific design_id if --design-id
+// is passed), in parallel, with concurrency capped.
+
+const { execSync } = require('child_process');
+// Match wallco-ai/server.js: socket auth on macOS, sudo-as-postgres on Linux
+const DB = process.env.DB_NAME || 'dw_unified';
+const PSQL_CMD = (process.platform === 'linux')
+  ? `sudo -n -u postgres psql ${DB} -At -F"|" -q`
+  : `psql ${DB} -At -F"|" -q`;
+function psql(sql) {
+  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim();
+}
+
+const args = process.argv.slice(2);
+const arg = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i+1] : d; };
+const FIX_DESIGN_ID = arg('--design-id', null);
+const PARALLELISM = parseInt(arg('--parallelism', '3'), 10);
+const DRY_RUN = args.includes('--dry-run');
+const SERVER_URL = process.env.WALLCO_URL || 'http://127.0.0.1:9792';
+
+async function rerenderRoom(designId, roomType) {
+  const r = await fetch(`${SERVER_URL}/api/room`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ design_id: designId, roomType }),
+  });
+  if (!r.ok) {
+    const text = await r.text().catch(() => '');
+    return { ok: false, status: r.status, error: text.slice(0, 200) };
+  }
+  return r.json();
+}
+
+async function main() {
+  let rows;
+  if (FIX_DESIGN_ID) {
+    const id = parseInt(FIX_DESIGN_ID, 10);
+    const raw = psql(`SELECT DISTINCT sr.design_id, COALESCE(wr.room_type, 'bedroom') AS room_type FROM studio_renders sr LEFT JOIN wallco_rooms wr ON wr.id = sr.room_id WHERE sr.design_id = ${id} AND sr.deleted_at IS NULL`);
+    rows = raw.split('\n').filter(Boolean).map(line => {
+      const [design_id, room_type] = line.split('|');
+      return { design_id: parseInt(design_id, 10), room_type };
+    });
+    // If no studio_renders rows for that design, render the canonical 3
+    if (!rows.length) rows = ['bedroom', 'living_room', 'dining_room'].map(rt => ({ design_id: id, room_type: rt }));
+  } else {
+    const raw = psql(`SELECT DISTINCT sr.design_id, COALESCE(wr.room_type, 'bedroom') AS room_type FROM studio_renders sr LEFT JOIN wallco_rooms wr ON wr.id = sr.room_id WHERE sr.design_id IS NOT NULL AND sr.deleted_at IS NULL ORDER BY sr.design_id DESC`);
+    rows = raw.split('\n').filter(Boolean).map(line => {
+      const [design_id, room_type] = line.split('|');
+      return { design_id: parseInt(design_id, 10), room_type };
+    });
+  }
+
+  console.log(`🎨 Re-rendering ${rows.length} (design_id, room_type) pairs · parallelism=${PARALLELISM} · ${DRY_RUN ? '[DRY RUN]' : 'LIVE'}`);
+
+  let done = 0, ok = 0, fail = 0;
+  const t0 = Date.now();
+
+  // Concurrent worker pool — keep PARALLELISM tasks in flight
+  async function worker(idx) {
+    while (true) {
+      const i = idx + done;   // worker takes the next free index
+      const my = rows.shift();
+      if (!my) break;
+      done++;
+      const tag = `V${my.design_id} ${my.room_type}`;
+      try {
+        if (DRY_RUN) { console.log(`  · ${tag} (dry)`); ok++; continue; }
+        const result = await rerenderRoom(my.design_id, my.room_type);
+        if (result && result.ok && result.image_url) {
+          console.log(`  ✓ ${tag} → room_id ${result.room_id} → ${result.image_url}`);
+          ok++;
+        } else {
+          console.log(`  ✗ ${tag} → ${result.error || result.status || 'unknown'}`);
+          fail++;
+        }
+      } catch (e) {
+        console.log(`  ✗ ${tag} → ${e.message}`);
+        fail++;
+      }
+    }
+  }
+
+  const total = rows.length;
+  const workers = Array.from({ length: PARALLELISM }, (_, i) => worker(i));
+  await Promise.all(workers);
+
+  const dur = ((Date.now() - t0) / 1000).toFixed(1);
+  console.log(`\nDone: ${ok} re-rendered · ${fail} failed · ${dur}s (${(ok/dur).toFixed(2)}/s)`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
index 3497c1c..c2454fa 100644
--- a/server.js
+++ b/server.js
@@ -1088,9 +1088,12 @@ ${htmlHeader('/me/saved')}
   </div>
   ${designs.length
     ? `<div class="design-grid catalog-grid" id="saved-grid">${cards}</div>`
-    : `<div style="padding:48px 24px;text-align:center;color:var(--ink-soft,#555);font:14px/1.6 var(--sans);background:var(--card-bg,#faf8f3);border-radius:6px">
-         <p style="margin:0 0 12px">You haven't saved any designs yet.</p>
-         <p style="margin:0"><a href="/designs" style="color:var(--accent,#d2b15c)">Browse the catalog →</a> Tap the <strong>♥ Save</strong> button on any design.</p>
+    : `<div style="padding:56px 28px;text-align:center;color:var(--ink-soft,#555);font:14px/1.6 var(--sans);background:var(--card-bg,#faf8f3);border-radius:8px">
+         <div style="font-size:42px;line-height:1;margin-bottom:14px;color:var(--gold,#c9a14b)">♡</div>
+         <p style="margin:0 0 8px;font:300 22px/1.2 var(--serif,Georgia,serif);color:var(--ink,#111)">Nothing saved yet.</p>
+         <p style="margin:0 0 18px;max-width:38em;margin-inline:auto">Save designs you're considering for a project — they'll wait here until you're ready to build a proposal or request samples.</p>
+         <p style="margin:0"><a href="/designs" class="btn-outline" style="display:inline-block;padding:9px 18px;font:13px var(--sans);text-decoration:none">Browse the catalog →</a></p>
+         <p style="margin:14px 0 0;font:12px var(--sans);color:var(--ink-faint,#999)">Tap <strong>♥ Save</strong> on any design page.</p>
        </div>`}
 </section>
 </main>
@@ -5531,9 +5534,12 @@ ${htmlHeader('/basket')}
   <p id="basket-count-line" style="font:13px var(--sans,system-ui);color:var(--ink-soft,#555);margin:0 0 18px">${items.length ? `${items.length} of 12 selected.` : 'Your basket is empty.'}</p>
 
   ${items.length === 0 ? `
-    <div id="basket-empty" style="padding:40px;border:1px dashed var(--line,#ccc);border-radius:8px;text-align:center;font:14px var(--sans,system-ui);color:var(--ink-faint,#888)">
-      <p>Add designs from any design page using <strong>+ Sample</strong>.</p>
-      <p style="font:12px var(--sans,system-ui);margin-top:14px">Or share a link directly: <code>/basket?ids=72,161</code></p>
+    <div id="basket-empty" style="padding:48px 28px;border:1px dashed var(--line,#ccc);border-radius:8px;text-align:center;color:var(--ink-soft,#555);font:14px/1.6 var(--sans,system-ui);background:var(--card-bg,#faf8f3)">
+      <div style="font-size:36px;line-height:1;margin-bottom:12px">▢</div>
+      <p style="margin:0 0 8px;font:300 22px/1.2 var(--serif,Georgia,serif);color:var(--ink,#111)">Build a sample basket.</p>
+      <p style="margin:0 0 18px;max-width:38em;margin-inline:auto">Memo samples are <strong>free for the trade</strong>, ship in 3–5 business days, and are 8.5 × 11 in — enough to read the pattern in your project's light. Add up to 12, request once.</p>
+      <p style="margin:0"><a href="/designs" class="btn-outline" style="display:inline-block;padding:9px 18px;font:13px var(--sans);text-decoration:none">Browse the catalog →</a></p>
+      <p style="margin:14px 0 0;font:12px var(--sans);color:var(--ink-faint,#999)">Tap <strong>+ Sample</strong> on any design page · or share a basket link directly: <code>/basket?ids=72,161</code></p>
     </div>
 
     <!-- Discovery rail: random designs to start a basket -->
@@ -6594,9 +6600,20 @@ function localPathForDesign(d) {
 app.post('/api/room', async (req, res) => {
   const { design_id, roomType = 'bedroom', angle = 'straight_on',
           cameraDistance = 5, patternWidth = 27, patternHeight = 27 } = req.body || {};
-  const d = findDesign(design_id);
-  if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
-  const file = localPathForDesign(d);
+  // FRESH-PATH FIX (2026-05-12): the in-memory DESIGNS snapshot can be stale
+  // for designs that were composed/baked after server start — leading to the
+  // upstream renderer receiving the OLD pattern file. Always read local_path
+  // live from spoon_all_designs to guarantee we send the CURRENT pattern.
+  let file = null;
+  try {
+    const lp = psqlQuery(`SELECT local_path FROM spoon_all_designs WHERE id=${pgEsc(parseInt(design_id, 10))} LIMIT 1;`);
+    if (lp && lp.trim()) file = lp.trim();
+  } catch (e) { /* fall through to in-memory */ }
+  if (!file) {
+    const d = findDesign(design_id);
+    if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
+    file = localPathForDesign(d);
+  }
   if (!file || !fs.existsSync(file)) {
     return res.status(404).json({ ok: false, error: 'design file missing on disk', file });
   }

← 11c49ab wallco.ai · nav saved-count chip auto-refreshes via custom e  ·  back to Wallco Ai  ·  wallco.ai · /compare view choice (specs/grid) now persists i 38e639c →