← back to Quadrille Showroom
Image-model pipeline: $0 local seamless tiles + Gemini room backdrops (gated, cost-shown); window-handle refresh; README
6ae51b8d7eab2f2399a1c5f79e4f29fb175b4076 · 2026-06-26 11:09:52 -0700 · Steve
Files touched
M .gitignoreA README.mdM public/js/showroom.jsA scripts/gen-assets.jsA scripts/img_tools.py
Diff
commit 6ae51b8d7eab2f2399a1c5f79e4f29fb175b4076
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 26 11:09:52 2026 -0700
Image-model pipeline: $0 local seamless tiles + Gemini room backdrops (gated, cost-shown); window-handle refresh; README
---
.gitignore | 4 ++
README.md | 65 +++++++++++++++++++++
public/js/showroom.js | 3 +
scripts/gen-assets.js | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++
scripts/img_tools.py | 66 ++++++++++++++++++++++
5 files changed, 292 insertions(+)
diff --git a/.gitignore b/.gitignore
index 78d0bc1..a5e2879 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ build/
*.bak-*
*.pre-*
*.orig
+
+# generated image artifacts (rebuild via scripts/gen-assets.js)
+data/gen-tiles/
+data/gen-rooms/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5cf60bc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,65 @@
+# The Quadrille House — Endless Wings Wallcovering Showroom
+
+A 3D "Endless Wings" showroom (Three.js) where you walk past hinged sample-book
+**wings**, each showing a wallcovering tiled onto its panel, and a **Peruse
+Collection** button auto-tours the whole catalog endlessly.
+
+Built by fusing two existing local codebases:
+- the **Endless Wings engine** from `~/Projects/dw-showroom` (50-wing chrome rail,
+ WASD walk, fan-cascade book-flip, lazy textures), and
+- the **Quadrille House data** from `~/Projects/quadrille-house-site/data/house.json`,
+ filtered to the **883 wallcoverings** (878 China Seas + 5 Grasscloth — China Seas
+ is the wallpaper arm of the Quadrille umbrella).
+
+Standalone, behind basic-auth (`admin / DWSecure2024!`), port **7690**.
+
+## Run
+
+```sh
+npm install
+node scripts/build-showroom-data.js # build data/showroom-products.json from house.json
+node server.js # http://127.0.0.1:7690 (basic-auth)
+```
+
+## What's in it
+
+- **Endless wings** — 50 live wings at a time (perf-bounded); `‹ Prev` / `Next ›`
+ + per-brand jump page through all 883. Sort (Curated / Newest / Title / SKU /
+ Book) + density slider (20–80 wings).
+- **Peruse Collection** (`P`, or the gold button) — camera flies ~4 ft to each
+ wing, opens it like a sample book, dwells 3 s, advances, and **pages window→window,
+ wrapping last→first = truly endless.** `Space` stops and inspects; `Esc` / click /
+ WASD interrupt.
+- **Real wallpaper tiling** — every texture loads through the same-origin image
+ proxy (CORS-safe + readable canvas) and runs the **FullDesign seam gate** (JS port
+ of `skills/fulldesign`, verified at parity with the Python reference). Seamless →
+ `RepeatWrapping`; not seamless → contain-fit + a red "general wall image" flag bar.
+- **View on Designer Wallcoverings** CTA per wing (live products link out; held SKUs
+ show "Request a Memo Sample"). Room thumbnail in the detail panel.
+
+## Image-model pipeline — `scripts/gen-assets.js`
+
+Design rule: **don't let AI re-generate the real product.** Splitting by where
+invention is legitimate:
+
+| Layer | Method | Cost | Why |
+|---|---|---|---|
+| **Tiles** | local mirror book-match (`img_tools.py`) | **$0** | makes the *genuine* China Seas pattern tile seamlessly — no AI hallucination of the product |
+| **Rooms** | Gemini 2.5 Flash Image | ~$0.003/img | a backdrop *setting* is invented; the real swatch is passed as reference so the wall reads true |
+
+```sh
+node scripts/gen-assets.js # dry-run: classify + cost preview ($0)
+node scripts/gen-assets.js --tiles # build all ~552 $0 seamless tiles
+node scripts/gen-assets.js --rooms --execute --limit 25 --ceiling 0.10 # paid sample
+```
+
+Per-call + running **$ totals** are always printed (hard rule). `--rooms` never
+spends without `--execute` and stops at `--ceiling`. The server prefers a generated
+tile/room per-SKU at boot (`data/gen-tiles/<sku>.png`, `data/gen-rooms/<sku>.jpg` —
+gitignored, rebuildable). Measured: ~63% of swatches fail the seam gate (~552 tiles,
+$0); 519 lack a room shot (full Gemini run ≈ **$1.56**).
+
+## Deploy
+
+`.deploy.conf` targets pm2 `quadrille-showroom` on Kamatera `:7690`. Deploy is
+Steve-gated (`/deploy`).
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 97c0419..6dd120d 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -539,6 +539,9 @@ function rebuildWingWall() {
wingBoards = [];
wingRacks = [];
buildWingWall();
+ // Keep external/diagnostic handles pointing at the live arrays after a rebuild
+ window._wingBoards = wingBoards;
+ window._wingRacks = wingRacks;
// Refresh sidebar to reflect the new window
const list = document.getElementById('vendor-list');
if (list) list.innerHTML = '';
diff --git a/scripts/gen-assets.js b/scripts/gen-assets.js
new file mode 100644
index 0000000..f8430f2
--- /dev/null
+++ b/scripts/gen-assets.js
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * gen-assets.js — enrich the showroom wings with generated assets.
+ *
+ * Two layers (see the README in the showroom: tiles preserve the real design,
+ * rooms are invented settings where AI models are appropriate):
+ *
+ * TILES ($0 local) — for wallcoverings whose catalog photo FAILS the FullDesign
+ * seam gate (lrScore >= 30), build a mirror book-match tile
+ * (scripts/img_tools.py) so the GENUINE pattern tiles cleanly.
+ * Output: data/gen-tiles/<sku>.png (server prefers it per-SKU).
+ *
+ * ROOMS (paid) — for wallcoverings with no distinct room shot, render a room
+ * backdrop via Gemini 2.5 Flash Image (~$0.003/img). Invention
+ * is fine here — it's a setting, not the product.
+ * Output: data/gen-rooms/<sku>.jpg
+ *
+ * Usage:
+ * node scripts/gen-assets.js # dry-run: classify a sample + cost preview ($0)
+ * node scripts/gen-assets.js --tiles --limit N # build N local seamless tiles ($0)
+ * node scripts/gen-assets.js --rooms --execute --limit N --ceiling 2.00 # paid Gemini rooms
+ *
+ * Hard rule (Steve): always show $ cost. Per-call + running total printed; --rooms
+ * never spends without --execute, and stops at --ceiling.
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const DATA = path.join(__dirname, '..', 'data');
+const TILES = path.join(DATA, 'gen-tiles');
+const ROOMS = path.join(DATA, 'gen-rooms');
+const PY = path.join(__dirname, 'img_tools.py');
+const ROOM_COST = 0.003; // Gemini 2.5 Flash Image, per render
+
+const args = process.argv.slice(2);
+const has = (f) => args.includes(f);
+const val = (f, d) => { const i = args.indexOf(f); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
+const LIMIT = parseInt(val('--limit', '0')) || 0;
+const CEILING = parseFloat(val('--ceiling', '1.00'));
+const EXECUTE = has('--execute');
+
+const data = JSON.parse(fs.readFileSync(path.join(DATA, 'showroom-products.json'), 'utf8'));
+const products = data.products;
+
+function score(url) {
+ try { return parseFloat(execFileSync('python3', [PY, 'score', url], { encoding: 'utf8', timeout: 30000 }).trim()); }
+ catch { return 999; }
+}
+
+function dryRun() {
+ console.log(`\n[gen-assets] DRY RUN — no writes, no spend. catalog: ${products.length} wallcoverings\n`);
+ // Sample-classify for a fail-rate estimate (full classify happens during --tiles).
+ const SAMPLE = Math.min(40, products.length);
+ const step = Math.max(1, Math.floor(products.length / SAMPLE));
+ let fails = 0, tested = 0;
+ for (let i = 0; i < products.length && tested < SAMPLE; i += step) {
+ const s = score(products[i].image);
+ if (s >= 30) fails++;
+ tested++;
+ process.stdout.write(`\r sampling FullDesign… ${tested}/${SAMPLE}`);
+ }
+ const failRate = fails / tested;
+ const tilesNeeded = Math.round(failRate * products.length);
+ const noRoom = products.filter(p => !p.room).length;
+ console.log(`\n\n Tiles: ~${(failRate * 100).toFixed(0)}% of photos fail the seam gate → ~${tilesNeeded} mirror tiles to build`);
+ console.log(` method: $0 local mirror book-match (preserves the real design)`);
+ console.log(` Rooms: ${noRoom} wallcoverings have no distinct room shot`);
+ console.log(` Gemini 2.5 Flash Image @ $${ROOM_COST}/img → full run ≈ $${(noRoom * ROOM_COST).toFixed(2)}`);
+ console.log(`\n Next:`);
+ console.log(` node scripts/gen-assets.js --tiles --limit 20 # $0, build sample tiles`);
+ console.log(` node scripts/gen-assets.js --rooms --execute --limit 25 --ceiling 0.10 # ~$0.08 sample`);
+ console.log(` (full room run is ~$${(noRoom * ROOM_COST).toFixed(2)} — Steve's go before the big batch)\n`);
+}
+
+function buildTiles() {
+ fs.mkdirSync(TILES, { recursive: true });
+ const wallcov = products.filter(p => p.image);
+ let built = 0, skipped = 0, checked = 0;
+ console.log(`\n[gen-assets] TILES ($0 local mirror book-match) limit=${LIMIT || 'all'}\n`);
+ for (const p of wallcov) {
+ if (LIMIT && built >= LIMIT) break;
+ const out = path.join(TILES, `${p.sku}.png`);
+ if (fs.existsSync(out)) { skipped++; continue; }
+ checked++;
+ let before;
+ try { before = parseFloat(execFileSync('python3', [PY, 'score', p.image], { encoding: 'utf8', timeout: 30000 }).trim()); }
+ catch { continue; }
+ if (before < 30) continue; // already seamless — no tile needed
+ try {
+ const res = execFileSync('python3', [PY, 'mirror', p.image, out], { encoding: 'utf8', timeout: 60000 }).trim();
+ const [b, a] = res.split(' ');
+ built++;
+ console.log(` ✓ ${p.sku} seam ${b} → ${a} $0 (local) [${built}${LIMIT ? '/' + LIMIT : ''}]`);
+ } catch (e) { console.log(` ✗ ${p.sku} ${e.message}`); }
+ }
+ console.log(`\n built ${built} tiles · skipped ${skipped} existing · cost $0 (local).`);
+ console.log(` Restart the server to pick them up (loaded per-SKU at boot).`);
+}
+
+async function buildRooms() {
+ fs.mkdirSync(ROOMS, { recursive: true });
+ const need = products.filter(p => !p.room && (!fs.existsSync(path.join(ROOMS, `${p.sku}.jpg`))));
+ const key = process.env.GEMINI_API_KEY;
+ let spent = 0, made = 0;
+ console.log(`\n[gen-assets] ROOMS (Gemini 2.5 Flash Image @ $${ROOM_COST}/img) execute=${EXECUTE} ceiling=$${CEILING.toFixed(2)} limit=${LIMIT || 'all'}\n`);
+ if (!EXECUTE) { console.log(` --execute not set → no spend. ${need.length} candidates, full run ≈ $${(need.length * ROOM_COST).toFixed(2)}.`); return; }
+ if (!key) { console.log(' GEMINI_API_KEY not in env → cannot render. Route it via the secrets skill, then re-run.'); return; }
+ for (const p of need) {
+ if (LIMIT && made >= LIMIT) break;
+ if (spent + ROOM_COST > CEILING) { console.log(`\n ceiling $${CEILING.toFixed(2)} reached — stopping.`); break; }
+ try {
+ await renderRoomGemini(p, key, path.join(ROOMS, `${p.sku}.jpg`));
+ spent += ROOM_COST; made++;
+ console.log(` ✓ ${p.sku} room rendered $${ROOM_COST.toFixed(3)} (running $${spent.toFixed(3)}) [${made}${LIMIT ? '/' + LIMIT : ''}]`);
+ } catch (e) { console.log(` ✗ ${p.sku} ${e.message}`); }
+ }
+ console.log(`\n rendered ${made} rooms · spent $${spent.toFixed(3)}.`);
+ // Best-effort cost-tracker log
+ try {
+ const log = path.join(process.env.HOME, '.claude', 'skills', 'cost-tracker', 'log.js');
+ if (made && fs.existsSync(log)) execFileSync('node', [log, 'gemini-image', String(made), String(spent)], { stdio: 'ignore' });
+ } catch {}
+}
+
+// Gemini 2.5 Flash Image — composite the real swatch into a styled room (the setting
+// is invented; the product texture is passed as inline_data so the wall reads true).
+async function renderRoomGemini(product, key, outPath) {
+ const https = require('https');
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${key}`;
+ // Fetch the swatch bytes (via the public CDN) to send as reference.
+ const swatch = await fetchBuffer(product.rawImage || product.image);
+ const body = JSON.stringify({
+ contents: [{ parts: [
+ { text: `Photorealistic interior: a refined living room with this ${product.vendor} wallcovering pattern applied to the main wall, natural daylight, tasteful furniture, designer styling. Keep the wallpaper pattern faithful to the reference image.` },
+ { inline_data: { mime_type: 'image/jpeg', data: swatch.toString('base64') } },
+ ]}],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ });
+ const json = await postJSON(url, body);
+ const part = ((((json.candidates || [])[0] || {}).content || {}).parts || []).find(p => p.inline_data || p.inlineData);
+ const b64 = part && (part.inline_data ? part.inline_data.data : part.inlineData.data);
+ if (!b64) throw new Error('no image in response');
+ fs.writeFileSync(outPath, Buffer.from(b64, 'base64'));
+
+ function fetchBuffer(u) { return new Promise((res, rej) => { https.get(u, r => { const c = []; r.on('data', d => c.push(d)); r.on('end', () => res(Buffer.concat(c))); }).on('error', rej); }); }
+ function postJSON(u, payload) { return new Promise((res, rej) => { const r = https.request(u, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, resp => { const c = []; resp.on('data', d => c.push(d)); resp.on('end', () => { try { res(JSON.parse(Buffer.concat(c).toString())); } catch (e) { rej(e); } }); }); r.on('error', rej); r.write(payload); r.end(); }); }
+}
+
+(async () => {
+ if (has('--tiles')) buildTiles();
+ else if (has('--rooms')) await buildRooms();
+ else dryRun();
+})();
diff --git a/scripts/img_tools.py b/scripts/img_tools.py
new file mode 100644
index 0000000..e984b65
--- /dev/null
+++ b/scripts/img_tools.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+"""img_tools.py — local ($0) image helpers for the Quadrille showroom gen pipeline.
+
+ python3 img_tools.py score <url|path> -> prints LR seam score (float)
+ python3 img_tools.py mirror <url|path> <out.png> -> writes a 2x2 mirror book-match
+ tile (guaranteed seamless,
+ preserves the real design),
+ prints "<before> <after>"
+
+The mirror book-match is a legitimate wallcovering technique: reflecting the swatch
+makes every tile edge match its neighbour exactly, so the genuine China Seas pattern
+tiles cleanly on a wall WITHOUT any AI re-generation (which would misrepresent the
+real product). $0 — no API, ~150ms/image.
+"""
+import sys, urllib.request, tempfile
+import numpy as np
+from PIL import Image
+
+STRIP = 3
+THRESHOLD = 30
+
+
+def _resolve(src):
+ if src.startswith('http'):
+ tmp = tempfile.NamedTemporaryFile(suffix='.img', delete=False)
+ urllib.request.urlretrieve(src, tmp.name)
+ return tmp.name
+ return src
+
+
+def lr_score(img):
+ w, h = img.size
+ sw, sh = min(w, 400), min(h, 800)
+ d = np.array(img.resize((sw, sh)).convert('RGB')).astype(int)
+ left = d[:, :STRIP, :]
+ right = d[:, sw - STRIP:, :][:, ::-1, :] # mirror so col0<->last
+ return float(np.abs(left - right).sum() / (sh * STRIP) / 3)
+
+
+def score(src):
+ print(round(lr_score(Image.open(_resolve(src))), 1))
+
+
+def mirror(src, out):
+ im = Image.open(_resolve(src)).convert('RGB')
+ before = round(lr_score(im), 1)
+ w, h = im.size
+ fh = im.transpose(Image.FLIP_LEFT_RIGHT)
+ fv = im.transpose(Image.FLIP_TOP_BOTTOM)
+ fb = fh.transpose(Image.FLIP_TOP_BOTTOM)
+ tile = Image.new('RGB', (w * 2, h * 2))
+ tile.paste(im, (0, 0)); tile.paste(fh, (w, 0))
+ tile.paste(fv, (0, h)); tile.paste(fb, (w, h))
+ tile.save(out, 'PNG')
+ after = round(lr_score(tile), 1)
+ print(f"{before} {after}")
+
+
+if __name__ == '__main__':
+ cmd = sys.argv[1] if len(sys.argv) > 1 else ''
+ if cmd == 'score' and len(sys.argv) == 3:
+ score(sys.argv[2])
+ elif cmd == 'mirror' and len(sys.argv) == 4:
+ mirror(sys.argv[2], sys.argv[3])
+ else:
+ print(__doc__); sys.exit(2)
← 592fa6a Quadrille House Endless Wings showroom — fused engine + 883
·
back to Quadrille Showroom
·
Record DTD hosting verdict (C: keep local, defer deploy; Opt b98ec59 →