← 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); });