← back to Quadrille Showroom
scripts/gen-assets.js
155 lines
#!/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();
})();