← back to Quadrille Showroom
scripts/gen-room-textures.js
127 lines
#!/usr/bin/env node
/**
* gen-room-textures.js — photoreal, tileable PBR-ish room textures via Replicate SDXL.
*
* Replaces the procedural canvas floor + plaster walls in showroom.js with real
* photographic textures so the scene reads as a PJ-grade showroom PHOTO, not a
* canvas-drawn 3D demo. Outputs 1024² (SDXL native) seamless textures to
* public/textures/<name>.png — the showroom loads them as MeshStandardMaterial maps
* with RepeatWrapping + max anisotropy.
*
* HARD RULE (Steve): always show $ cost. SDXL on Replicate ≈ $0.011/image. Per-call +
* running total printed. --execute required to spend; --ceiling caps it.
*
* node scripts/gen-room-textures.js # dry-run: prompts + cost preview ($0)
* node scripts/gen-room-textures.js --execute --ceiling 0.20
*/
const fs = require('fs');
const path = require('path');
const OUT = path.join(__dirname, '..', 'public', 'textures');
fs.mkdirSync(OUT, { recursive: true });
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 EXECUTE = has('--execute');
const CEILING = parseFloat(val('--ceiling', '0.20'));
// SDXL on Replicate: ~ $0.011 per 1024² image (A40, ~6-8s).
const COST_PER_IMG = 0.011;
const TOKEN = (() => {
try {
const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
const m = env.match(/^REPLICATE_API_TOKEN=(.+)$/m);
return m ? m[1].trim() : process.env.REPLICATE_API_TOKEN;
} catch { return process.env.REPLICATE_API_TOKEN; }
})();
// SDXL model version (stability-ai/sdxl). Seamless via "tileable" prompt cues +
// shared seed; SDXL has no native tiling toggle, so prompts emphasise a flat
// top-down planar swatch that mirror-tiles acceptably for a floor/wall ground.
const SDXL_VERSION = '7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc';
const TEXTURES = [
{
name: 'floor-oak',
prompt: 'top-down flat-lay photograph of a warm honey-oak wide-plank wood floor, ' +
'satin matte finish, fine natural grain, subtle plank seams running vertically, ' +
'evenly lit soft daylight, no shadows, no objects, no people, ' +
'seamless tileable texture, architectural material swatch, photoreal, 8k, sharp',
negative: 'shadow, vignette, dark corners, perspective, furniture, rug, reflection glare, ' +
'cartoon, illustration, low resolution, blurry, text, watermark, pattern, tile grout',
},
{
name: 'wall-limewash',
prompt: 'flat photograph of a smooth matte limewash plaster wall, soft warm off-white ' +
'cream colour, faint hand-troweled cloudy mottle, very subtle texture, ' +
'bright even gallery lighting, no shadows, no objects, ' +
'seamless tileable wall surface, architectural material swatch, photoreal, 8k, sharp',
negative: 'brick, stone, cracks, mold, stain, shadow, vignette, dark, perspective, ' +
'furniture, cartoon, illustration, low resolution, blurry, text, watermark, heavy texture',
},
{
name: 'floor-stone',
prompt: 'top-down flat-lay photograph of a honed pale travertine stone floor, ' +
'large format, soft warm beige, very subtle veining, matte non-reflective, ' +
'evenly lit soft daylight, no shadows, no objects, ' +
'seamless tileable texture, architectural material swatch, photoreal, 8k, sharp',
negative: 'shadow, vignette, dark corners, perspective, furniture, glossy reflection, ' +
'cartoon, illustration, low resolution, blurry, text, watermark, heavy veins, cracks',
},
];
async function sdxl(t, seed) {
const create = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json', 'Prefer': 'wait' },
body: JSON.stringify({
version: SDXL_VERSION,
input: {
prompt: t.prompt, negative_prompt: t.negative,
width: 1024, height: 1024, num_inference_steps: 32,
guidance_scale: 7, scheduler: 'K_EULER', seed, num_outputs: 1, apply_watermark: false,
},
}),
});
let pred = await create.json();
// Poll if not resolved by Prefer:wait
const t0 = Date.now();
while (pred.status && !['succeeded', 'failed', 'canceled'].includes(pred.status) && Date.now() - t0 < 120000) {
await new Promise(r => setTimeout(r, 1500));
const g = await fetch(pred.urls.get, { headers: { 'Authorization': `Bearer ${TOKEN}` } });
pred = await g.json();
}
if (pred.status !== 'succeeded') throw new Error('SDXL ' + (pred.status || 'no-status') + ' ' + JSON.stringify(pred.error || pred.detail || ''));
const out = Array.isArray(pred.output) ? pred.output[0] : pred.output;
const img = await fetch(out);
return Buffer.from(await img.arrayBuffer());
}
(async () => {
console.log(`gen-room-textures — ${TEXTURES.length} SDXL textures @ ~$${COST_PER_IMG}/img`);
console.log(` est total: $${(TEXTURES.length * COST_PER_IMG).toFixed(3)} (ceiling $${CEILING.toFixed(2)})`);
if (!EXECUTE) {
console.log('\nDRY RUN ($0) — pass --execute to generate. Prompts:');
TEXTURES.forEach(t => console.log(` • ${t.name}: ${t.prompt.slice(0, 80)}…`));
return;
}
if (!TOKEN) { console.error('No REPLICATE_API_TOKEN'); process.exit(1); }
let spent = 0;
for (const t of TEXTURES) {
if (spent + COST_PER_IMG > CEILING) { console.log(` ⛔ ceiling $${CEILING} hit at $${spent.toFixed(3)} — stopping`); break; }
process.stdout.write(` → ${t.name} … `);
try {
const buf = await sdxl(t, 42);
const dest = path.join(OUT, `${t.name}.png`);
fs.writeFileSync(dest, buf);
spent += COST_PER_IMG;
console.log(`saved ${(buf.length / 1024) | 0}KB · this $${COST_PER_IMG.toFixed(3)} · running $${spent.toFixed(3)}`);
} catch (e) {
console.log(`FAIL: ${e.message}`);
}
}
console.log(`\nTOTAL SPENT: $${spent.toFixed(3)} (Replicate SDXL)`);
})().catch(e => { console.error('FAILED:', e.message); process.exit(1); });