[object Object]

← back to Interiordesignershowroom

auto-save: 2026-08-01T21:09:29 (7 files) — lib/scene.js package-lock.json package.json server.js _ctest.js

3448c99523496ce2e4b5c0303d85da1a65f2ecc6 · 2026-08-01 21:09:34 -0700 · Steve Abrams

Files touched

Diff

commit 3448c99523496ce2e4b5c0303d85da1a65f2ecc6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 21:09:34 2026 -0700

    auto-save: 2026-08-01T21:09:29 (7 files) — lib/scene.js package-lock.json package.json server.js _ctest.js
---
 _ctest.js                |  20 ++
 _scene_test.js           |  37 +++
 lib/scene.js             |  63 ++++-
 package-lock.json        | 638 ++++++++++++++++++++++++++++++++++++++++++++++-
 package.json             |   3 +-
 scripts/enrich-colors.js | 172 +++++++++++++
 server.js                |  14 +-
 7 files changed, 933 insertions(+), 14 deletions(-)

diff --git a/_ctest.js b/_ctest.js
new file mode 100644
index 0000000..3e32a51
--- /dev/null
+++ b/_ctest.js
@@ -0,0 +1,20 @@
+const sharp = require('sharp');
+const db = require('./lib/db');
+function rgbToHsv(r,g,b){r/=255;g/=255;b/=255;const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn;let h=0;if(d){if(mx===r)h=((g-b)/d)%6;else if(mx===g)h=(b-r)/d+2;else h=(r-g)/d+4;h*=60;if(h<0)h+=360;}return{h,s:mx===0?0:d/mx,v:mx};}
+function bucketFor(r,g,b){const{h,s,v}=rgbToHsv(r,g,b);if(s<0.12){if(v<0.2)return'black';if(v<0.65)return'gray';return'neutral';}const warm=(h>=345||h<45);if(v<0.45&&warm)return'brown';if(h>=345||h<15)return'red';if(h<45)return'brown';if(h<70)return'yellow';if(h<165)return'green';if(h<255)return'blue';if(h<285)return'blue';if(h<345)return'pink';return'red';}
+(async()=>{
+  const {rows}=await db.query("SELECT id,title,image_url FROM products WHERE image_url IS NOT NULL AND NOT is_wall_paint ORDER BY random() LIMIT 12");
+  for(const r of rows){
+    try{
+      const ctrl=new AbortController();const t=setTimeout(()=>ctrl.abort(),8000);
+      const res=await fetch(r.image_url,{signal:ctrl.signal,headers:{'User-Agent':'test'}});clearTimeout(t);
+      const buf=Buffer.from(await res.arrayBuffer());
+      const {data,info}=await sharp(buf).resize(16,16,{fit:'inside'}).removeAlpha().raw().toBuffer({resolveWithObject:true});
+      const ch=info.channels;let rw=0,gw=0,bw=0,ws=0;
+      for(let i=0;i+ch-1<data.length;i+=ch){const R=data[i],G=data[i+1],B=data[i+2];const nw=R>235&&G>235&&B>235;const w=nw?0.08:1;rw+=R*w;gw+=G*w;bw+=B*w;ws+=w;}
+      const R=rw/ws,G=gw/ws,B=bw/ws;
+      console.log(bucketFor(R,G,B).padEnd(8),`rgb(${R|0},${G|0},${B|0})`.padEnd(18),(r.title||'').slice(0,45));
+    }catch(e){console.log('SKIP    ',e.message,r.id);}
+  }
+  await db.pool.end();
+})();
diff --git a/_scene_test.js b/_scene_test.js
new file mode 100644
index 0000000..5323e84
--- /dev/null
+++ b/_scene_test.js
@@ -0,0 +1,37 @@
+// throwaway test for AD-grade room renders
+require('dotenv').config();
+const { generateScene } = require('./lib/scene');
+
+const P = (u) => ({ image_url: u });
+const cases = [
+  {
+    style: 'mid-century', color: 'walnut', room_type: 'living-room',
+    products: [
+      P('https://assets.wfcdn.com/im/46783967/resize-h400-w400%5Ecompr-r85/2879/287947641/.jpg'),
+      P('https://assets.wfcdn.com/im/88054150/resize-h400-w400%5Ecompr-r85/2908/290875615/.jpg'),
+    ],
+  },
+  {
+    style: 'coastal', color: 'blue', room_type: 'bedroom',
+    products: [
+      P('https://assets.wfcdn.com/im/24851625/resize-h400-w400%5Ecompr-r85/3349/334967974/.jpg'),
+      P('https://assets.wfcdn.com/im/96917214/resize-h400-w400%5Ecompr-r85/3865/386515045/.jpg'),
+    ],
+  },
+  { style: 'organic modern', theme: 'warm minimal', color: 'terracotta', room_type: 'dining-room' },
+];
+
+(async () => {
+  let total = 0;
+  for (const c of cases) {
+    try {
+      const r = await generateScene(c);
+      total += r.cost;
+      console.log(`OK  ${c.style}/${c.color}/${c.room_type}  -> ${r.url}  refs=${r.refs}  $${r.cost}`);
+    } catch (e) {
+      console.log(`ERR ${c.style}/${c.room_type}: ${e.message}`);
+    }
+  }
+  console.log(`\nTOTAL RENDER COST: $${total.toFixed(3)}`);
+  process.exit(0);
+})();
diff --git a/lib/scene.js b/lib/scene.js
index 3652a67..a6c009e 100644
--- a/lib/scene.js
+++ b/lib/scene.js
@@ -26,19 +26,72 @@ async function generateScene({ style, color, theme, period, room_type, wall, pro
   if (!key) throw new Error('GEMINI_API_KEY not set');
   fs.mkdirSync(OUT_DIR, { recursive: true });
 
-  const vibe = [period, style, theme].filter(Boolean).join(' ') || 'contemporary';
+  const vibe = [period, style, theme].filter(Boolean).join(', ') || 'contemporary';
   const roomWord = (room_type || 'room').replace(/-/g, ' ');
-  const palette = color ? `${color} color palette` : 'cohesive, tasteful palette';
-  const wallTxt = wall ? `, walls painted ${wall}` : '';
+  const palette = color
+    ? `a refined, largely neutral palette (warm whites, greige, natural wood, stone) grounded and accented by ${color} tones`
+    : 'a refined neutral-plus-accent palette (warm whites, greige, natural wood, stone with one restrained accent)';
+  const wallTxt = wall ? ` The walls are finished in ${wall}.` : '';
 
   const parts = [];
   for (const u of (products || []).map(p => p.image_url).filter(Boolean).slice(0, 4)) {
     const inl = await fetchInline(u);
     if (inl) parts.push(inl);
   }
+
+  // Shared "make it read like Architectural Digest" photography direction.
+  const camera =
+    'Shoot it as a professional architectural interior photograph on a full-frame DSLR ' +
+    'with a natural ~24mm wide-angle lens (wide but undistorted — absolutely no fisheye ' +
+    'curvature, straight verticals), eye-level, with a rule-of-thirds composition and ' +
+    'comfortable headroom above the furniture.';
+  const light =
+    'The room has a real window letting in soft, directional natural daylight that ' +
+    'produces gentle, believable shadows and warm falloff across the space — the calm, ' +
+    'layered, expensive quality of light in Architectural Digest, Kelly Wearstler, and ' +
+    'Studio McGee interiors.';
+  const styling =
+    'Style it tastefully and sparingly: a few hardcover books, a couple of ceramic ' +
+    'vessels, a live plant or fresh cut branches, a framed piece of art, a soft throw or ' +
+    'linen textile — layered but calm, never cluttered.';
+  const materials =
+    `Use real, photorealistic natural materials — wood grain, linen, wool, stone, brass, ` +
+    `glass, aged leather — in ${palette}, cohesive with a ${vibe} sensibility.${wallTxt}`;
+  const negative =
+    'It MUST be ONE single continuous photographed room — never a grid, collage, ' +
+    'contact sheet, product catalog, moodboard, or side-by-side comparison. No text, ' +
+    'no captions, no labels, no watermarks, no logos, no brand marks, no people, no ' +
+    'pets, no reflections of a camera. Furniture must sit at correct human scale with ' +
+    'no warped, melted, duplicated, or distorted pieces, and no floating objects.';
+
   const instruction = parts.length
-    ? `Create ONE photorealistic, magazine-quality interior photograph of a ${vibe} ${roomWord} that naturally incorporates the exact furniture and decor pieces shown in the reference images, arranged believably in a real, styled room with a ${palette}${wallTxt}. Wide-angle, soft natural daylight, realistic shadows and materials. No text, no watermarks, no collage — a single cohesive room photo.`
-    : `Create ONE photorealistic, magazine-quality interior photograph of a ${vibe} ${roomWord} with a ${palette}${wallTxt}, tastefully furnished and styled. Wide-angle, soft natural daylight. No text, no watermarks.`;
+    ? [
+        `A high-end interior-design editorial photograph of a ${vibe} ${roomWord}.`,
+        `Take the EXACT furniture and decor pieces shown in the reference images and ` +
+          `integrate them naturally into ONE cohesive, believably-designed room — place ` +
+          `each piece where a real interior designer would put it, at correct scale ` +
+          `relative to the room and to each other, resting on the floor or surfaces with ` +
+          `contact shadows, matching their real materials and finishes. They are the ` +
+          `hero pieces of a single lived-in space, NOT products laid out on a page.`,
+        camera,
+        light,
+        materials,
+        styling,
+        negative,
+        `The final image should look like a full-page flagship photograph pulled ` +
+          `straight from Architectural Digest.`,
+      ].join(' ')
+    : [
+        `A high-end interior-design editorial photograph of a ${vibe} ${roomWord}, ` +
+          `tastefully furnished and styled as one cohesive, believably-designed space.`,
+        camera,
+        light,
+        materials,
+        styling,
+        negative,
+        `The final image should look like a full-page flagship photograph pulled ` +
+          `straight from Architectural Digest.`,
+      ].join(' ');
   parts.push({ text: instruction });
 
   const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${key}`, {
diff --git a/package-lock.json b/package-lock.json
index a8851a3..b2b7154 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,12 +10,571 @@
       "dependencies": {
         "dotenv": "^17.4.2",
         "express": "^4.19.2",
-        "pg": "^8.12.0"
+        "pg": "^8.12.0",
+        "sharp": "^0.35.3"
       },
       "engines": {
         "node": ">=20"
       }
     },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.3",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+      "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+      "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+      "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-freebsd-wasm32": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+      "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "dependencies": {
+        "@img/sharp-wasm32": "0.35.3"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+      "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+      "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+      "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+      "cpu": [
+        "arm"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+      "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+      "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+      "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+      "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+      "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+      "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+      "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+      "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+      "cpu": [
+        "arm"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+      "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+      "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+      "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+      "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+      "cpu": [
+        "s390x"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+      "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+      "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+      "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+      "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.11.1"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-webcontainers-wasm32": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+      "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "@img/sharp-wasm32": "0.35.3"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+      "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+      "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+      "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
     "node_modules/accepts": {
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -161,6 +720,15 @@
         "npm": "1.2.8000 || >= 1.4.16"
       }
     },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/dotenv": {
       "version": "17.4.2",
       "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -788,6 +1356,18 @@
       "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
       "license": "MIT"
     },
+    "node_modules/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/send": {
       "version": "0.19.2",
       "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -839,6 +1419,55 @@
       "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
       "license": "ISC"
     },
+    "node_modules/sharp": {
+      "version": "0.35.3",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+      "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@img/colour": "^1.1.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.8.5"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.35.3",
+        "@img/sharp-darwin-x64": "0.35.3",
+        "@img/sharp-freebsd-wasm32": "0.35.3",
+        "@img/sharp-libvips-darwin-arm64": "1.3.2",
+        "@img/sharp-libvips-darwin-x64": "1.3.2",
+        "@img/sharp-libvips-linux-arm": "1.3.2",
+        "@img/sharp-libvips-linux-arm64": "1.3.2",
+        "@img/sharp-libvips-linux-ppc64": "1.3.2",
+        "@img/sharp-libvips-linux-riscv64": "1.3.2",
+        "@img/sharp-libvips-linux-s390x": "1.3.2",
+        "@img/sharp-libvips-linux-x64": "1.3.2",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+        "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+        "@img/sharp-linux-arm": "0.35.3",
+        "@img/sharp-linux-arm64": "0.35.3",
+        "@img/sharp-linux-ppc64": "0.35.3",
+        "@img/sharp-linux-riscv64": "0.35.3",
+        "@img/sharp-linux-s390x": "0.35.3",
+        "@img/sharp-linux-x64": "0.35.3",
+        "@img/sharp-linuxmusl-arm64": "0.35.3",
+        "@img/sharp-linuxmusl-x64": "0.35.3",
+        "@img/sharp-webcontainers-wasm32": "0.35.3",
+        "@img/sharp-win32-arm64": "0.35.3",
+        "@img/sharp-win32-ia32": "0.35.3",
+        "@img/sharp-win32-x64": "0.35.3"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/side-channel": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -938,6 +1567,13 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD",
+      "optional": true
+    },
     "node_modules/type-is": {
       "version": "1.6.18",
       "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
diff --git a/package.json b/package.json
index 6e8919b..32b4d69 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,8 @@
   "dependencies": {
     "dotenv": "^17.4.2",
     "express": "^4.19.2",
-    "pg": "^8.12.0"
+    "pg": "^8.12.0",
+    "sharp": "^0.35.3"
   },
   "engines": {
     "node": ">=20"
diff --git a/scripts/enrich-colors.js b/scripts/enrich-colors.js
new file mode 100644
index 0000000..a84d271
--- /dev/null
+++ b/scripts/enrich-colors.js
@@ -0,0 +1,172 @@
+#!/usr/bin/env node
+// TK-10112 — Classify each product's dominant color FROM ITS IMAGE so the Room
+// Builder's 12-color filter is meaningful. products.color was title-derived, so
+// almost everything landed in 'neutral' and the color swatches returned nothing.
+//
+// For each product (NOT is_wall_paint, image_url present): download the image,
+// downsample to 16x16, average RGB across pixels while down-weighting near-white
+// background pixels (product photos are usually shot on white), then map the
+// average color -> HSV -> one of the 12-color-filter buckets and UPDATE
+// products.color.
+//
+// Buckets (must match the Room Builder filter, server.js COLORS_12 + presets):
+//   neutral, gray, black, brown, blue, green, pink, yellow, red
+//
+// Safe/reversible: only touches products.color. Resumable — pass --only-neutral
+// to reprocess just the un-enriched rows. Concurrency-capped, 8s per-image
+// timeout, skips (leaves color unchanged) on any download/decode failure.
+
+const db = require('../lib/db');
+const sharp = require('sharp');
+
+const CONCURRENCY = 8;
+const FETCH_TIMEOUT_MS = 8000;
+const PROGRESS_EVERY = 200;
+
+// ---- color math -----------------------------------------------------------
+
+function rgbToHsv(r, g, b) {
+  r /= 255; g /= 255; b /= 255;
+  const max = Math.max(r, g, b), min = Math.min(r, g, b);
+  const d = max - min;
+  let h = 0;
+  if (d !== 0) {
+    if (max === r) h = ((g - b) / d) % 6;
+    else if (max === g) h = (b - r) / d + 2;
+    else h = (r - g) / d + 4;
+    h *= 60;
+    if (h < 0) h += 360;
+  }
+  const s = max === 0 ? 0 : d / max;
+  const v = max;
+  return { h, s, v };
+}
+
+// Map an average RGB to one of the 9 filter buckets.
+function bucketFor(r, g, b) {
+  const { h, s, v } = rgbToHsv(r, g, b);
+
+  // Low-saturation → grayscale family (neutral / gray / black).
+  if (s < 0.12) {
+    if (v < 0.2) return 'black';
+    if (v < 0.65) return 'gray';
+    return 'neutral';
+  }
+
+  // Chromatic. A dark, warm, muted color reads as brown (wood/leather/taupe)
+  // rather than a saturated red/orange.
+  const warm = (h >= 345 || h < 45);
+  if (v < 0.45 && warm) return 'brown';
+
+  if (h >= 345 || h < 15) return 'red';       // red
+  if (h < 45) return 'brown';                  // orange/amber → brown family
+  if (h < 70) return 'yellow';                 // yellow
+  if (h < 165) return 'green';                 // green
+  if (h < 255) return 'blue';                  // cyan→blue
+  if (h < 285) return 'blue';                  // purple → blue bucket (no purple swatch)
+  if (h < 345) return 'pink';                  // magenta/pink
+  return 'red';
+}
+
+// ---- per-image analysis ---------------------------------------------------
+
+async function fetchImage(url) {
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
+  try {
+    const res = await fetch(url, {
+      signal: ctrl.signal,
+      headers: { 'User-Agent': 'Mozilla/5.0 (color-enrich/1.0)' },
+    });
+    if (!res.ok) return null;
+    const ab = await res.arrayBuffer();
+    return Buffer.from(ab);
+  } catch {
+    return null;
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+async function dominantBucket(buf) {
+  // 16x16, alpha stripped, raw RGB. `fit: inside` preserves aspect so we don't
+  // stretch. removeAlpha() flattens onto nothing — but raw() gives 3 channels.
+  const { data, info } = await sharp(buf)
+    .resize(16, 16, { fit: 'inside' })
+    .removeAlpha()
+    .raw()
+    .toBuffer({ resolveWithObject: true });
+
+  const ch = info.channels; // 3 after removeAlpha
+  let rw = 0, gw = 0, bw = 0, wsum = 0;
+  let rAll = 0, gAll = 0, bAll = 0, n = 0;
+
+  for (let i = 0; i + ch - 1 < data.length; i += ch) {
+    const r = data[i], g = data[i + 1], b = data[i + 2];
+    rAll += r; gAll += g; bAll += b; n++;
+    // Down-weight near-white pixels (photo background). Non-white subject
+    // pixels get full weight so the SUBJECT color dominates the average.
+    const nearWhite = r > 235 && g > 235 && b > 235;
+    const w = nearWhite ? 0.08 : 1;
+    rw += r * w; gw += g * w; bw += b * w; wsum += w;
+  }
+  if (n === 0) return null;
+
+  // If the whole image is near-white (wsum collapsed to just background weight),
+  // fall back to the plain average so we still classify it (as neutral/gray).
+  let R, G, B;
+  if (wsum < 0.5) {
+    R = rAll / n; G = gAll / n; B = bAll / n;
+  } else {
+    R = rw / wsum; G = gw / wsum; B = bw / wsum;
+  }
+  return bucketFor(R, G, B);
+}
+
+// ---- driver ---------------------------------------------------------------
+
+async function main() {
+  const onlyNeutral = process.argv.includes('--only-neutral');
+  const where = onlyNeutral
+    ? "image_url IS NOT NULL AND NOT is_wall_paint AND (color IS NULL OR color = 'neutral')"
+    : 'image_url IS NOT NULL AND NOT is_wall_paint';
+
+  const { rows } = await db.query(
+    `SELECT id, image_url FROM products WHERE ${where} ORDER BY id`
+  );
+  console.log(`[enrich-colors] ${rows.length} products to classify (concurrency ${CONCURRENCY})`);
+
+  let done = 0, updated = 0, skipped = 0;
+  const tally = {};
+
+  let idx = 0;
+  async function worker() {
+    while (idx < rows.length) {
+      const row = rows[idx++];
+      try {
+        const buf = await fetchImage(row.image_url);
+        if (!buf) { skipped++; continue; }
+        const bucket = await dominantBucket(buf);
+        if (!bucket) { skipped++; continue; }
+        await db.query('UPDATE products SET color = $1 WHERE id = $2', [bucket, row.id]);
+        updated++;
+        tally[bucket] = (tally[bucket] || 0) + 1;
+      } catch {
+        skipped++;
+      } finally {
+        done++;
+        if (done % PROGRESS_EVERY === 0) {
+          console.log(`  ${done}/${rows.length}  updated=${updated} skipped=${skipped}`);
+        }
+      }
+    }
+  }
+
+  await Promise.all(Array.from({ length: CONCURRENCY }, worker));
+
+  console.log(`[enrich-colors] done. updated=${updated} skipped=${skipped}`);
+  console.log('[enrich-colors] this run bucketed:', JSON.stringify(tally));
+  await db.pool.end();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
index 9615165..47048d0 100644
--- a/server.js
+++ b/server.js
@@ -323,15 +323,15 @@ app.get('/build', (_req, res) => {
       </aside>
       <div class="rb-mid" id="rbMid">
         <div class="rb-render-bar">
-          <button id="rbRenderBtn" class="cta sm">🎨 Render photoreal room</button>
-          <span class="rb-cost" id="rbCost">~$0.039 per render (Gemini) · uses your selected pieces</span>
+          <button id="rbRenderBtn" class="cta">✨ Generate room setting</button>
+          <span class="rb-cost" id="rbCost">~$0.039 each · uses your vibe + selected pieces</span>
         </div>
-        <div class="rb-render" id="rbRender"><div class="rb-render-ph">Your room renders here.<br><small>Pick a vibe + drag pieces, then <b>Render</b> for a photoreal scene — or hit <b>GO</b> to save.</small></div></div>
-        <h4 class="rb-ideas-h">✨ New ideas <span class="subtle">— tap to add</span></h4>
-        <div class="rb-ideas" id="rbIdeas"></div>
+        <div class="rb-renders" id="rbRenders"><div class="rb-render-ph">Your generated room settings appear here.<br><small>Pick a vibe on the left (open <b>☰ Products</b> to add pieces), then <b>Generate</b>. Make several, pick your favorite, and hit <b>GO</b> to save.</small></div></div>
       </div>
-      <aside class="rb-right">
-        <input id="rbSearch" type="search" placeholder="Search catalog… (sofa, steelcase, vitamix)">
+      <button class="rb-ham" id="rbHam" title="Show products">☰ Products</button>
+      <aside class="rb-right collapsed" id="rbRight">
+        <div class="rb-right-head"><span>Products</span><button class="rb-ham-close" id="rbHamClose">✕</button></div>
+        <input id="rbSearch" type="search" placeholder="Search products…">
         <div class="rb-products" id="rbProducts"></div>
       </aside>
     </div>

← 61d7a3c Add Herman Miller + Steelcase brand ingest for testing  ·  back to Interiordesignershowroom  ·  Elevate site UI to steelcase.com-caliber editorial design 1012ec8 →