← back to Wallco Ai

scripts/build_texture_library.js

126 lines

#!/usr/bin/env node
'use strict';
/**
 * build_texture_library — wallco.ai's texture-background library, sourced from
 * REAL plain natural-fibre wallcovering products in the dw_unified database.
 *
 * Steve's rule: plain flat backgrounds look low-end. Compositing a generated
 * pattern over a genuine grasscloth / sisal / cork / raffia product photo gives
 * the render an honest, premium textile substrate.
 *
 * The intended filter was "36 inch wide" — the standard width for natural-fibre
 * goods — but dw_unified.products.width_in is unpopulated for ~99% of rows, so
 * width can't be filtered on. Instead we select by plain-texture MATERIAL and
 * exclude anything whose name reads as a print/pattern. ~416 products qualify.
 *
 * Output: public/textures/library/<bucket>/<slug>.jpg
 *         public/textures/library/index.json   (manifest, grouped by material)
 *
 *   node scripts/build_texture_library.js
 */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const OUT = path.join(__dirname, '..', 'public', 'textures', 'library');
fs.mkdirSync(OUT, { recursive: true });

const TEX_RE   = '(grasscloth|grass cloth|sisal|raffia|abaca|jute|hemp|cork|natural weave|paper weave|arrowroot|sea ?grass)';
const PRINT_RE = '(floral|damask|stripe|scenic|toile|chinoiserie|print|motif|trellis|paisley|ikat|medallion|leaf|leaves|bird|botanical|animal|geometric)';

// material bucket — first match wins
function bucket(blob) {
  if (/grass ?cloth/.test(blob))      return { slug: 'grasscloth', name: 'Grasscloth' };
  if (/sisal/.test(blob))             return { slug: 'sisal',      name: 'Sisal' };
  if (/raffia/.test(blob))            return { slug: 'raffia',     name: 'Raffia' };
  if (/abaca/.test(blob))             return { slug: 'abaca',      name: 'Abaca' };
  if (/sea ?grass|arrowroot/.test(blob)) return { slug: 'seagrass', name: 'Seagrass & Arrowroot' };
  if (/\bcork\b/.test(blob))          return { slug: 'cork',       name: 'Cork' };
  if (/jute|hemp/.test(blob))         return { slug: 'jute',       name: 'Jute & Hemp' };
  if (/paper weave|natural weave/.test(blob)) return { slug: 'weave', name: 'Paper & Natural Weave' };
  return { slug: 'natural', name: 'Other Natural Fibre' };
}

function slugify(s) {
  return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'tex';
}

// ---- pull rows from dw_unified -------------------------------------------
console.log('querying dw_unified for plain-texture products…');
const sql = `
  select coalesce(json_agg(row_to_json(t)), '[]') from (
    select sku, name, material, pattern_name,
      (case when array_length(image_urls,1)>0 then image_urls[1] else thumbnail_url end) as img
    from products
    where lower(coalesce(material,'')||' '||coalesce(name,'')||' '||coalesce(pattern_name,'')) ~ '${TEX_RE}'
      and lower(coalesce(material,'')||' '||coalesce(name,'')||' '||coalesce(pattern_name,'')) !~ '${PRINT_RE}'
      and (array_length(image_urls,1)>0 or thumbnail_url is not null)
  ) t`;
const raw = execSync(`psql -d dw_unified -tA -c ${JSON.stringify(sql.replace(/\s+/g, ' '))}`, { encoding: 'utf8', maxBuffer: 64 << 20 });
let rows = JSON.parse(raw.trim());
rows = rows.filter(r => r.img && /^https?:\/\//.test(r.img));
console.log(`  ${rows.length} products with a usable image URL`);

// dedupe by image URL — many SKUs share one flatshot
const seen = new Map();
for (const r of rows) if (!seen.has(r.img)) seen.set(r.img, r);
const uniq = [...seen.values()];
console.log(`  ${uniq.length} unique images after dedupe`);

// ---- download with concurrency -------------------------------------------
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
const manifest = {};
let ok = 0, fail = 0, done = 0;

async function fetchOne(r) {
  const blob = `${r.material || ''} ${r.name || ''} ${r.pattern_name || ''}`.toLowerCase();
  const b = bucket(blob);
  const dir = path.join(OUT, b.slug);
  fs.mkdirSync(dir, { recursive: true });
  const ext = (r.img.match(/\.(jpe?g|png|webp)(\?|$)/i) || [, 'jpg'])[1].toLowerCase().replace('jpeg', 'jpg');
  const file = `${slugify(r.sku)}.${ext}`;
  const dest = path.join(dir, file);
  try {
    if (!fs.existsSync(dest) || fs.statSync(dest).size < 1000) {
      const ctl = new AbortController();
      const timer = setTimeout(() => ctl.abort(), 20000);
      const res = await fetch(r.img, { headers: { 'User-Agent': UA }, signal: ctl.signal });
      clearTimeout(timer);
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const buf = Buffer.from(await res.arrayBuffer());
      if (buf.length < 1000) throw new Error('tiny (' + buf.length + 'B)');
      fs.writeFileSync(dest, buf);
    }
    (manifest[b.slug] ||= { name: b.name, textures: [] }).textures.push({
      sku: r.sku, name: (r.name || r.sku).trim(), file: `${b.slug}/${file}`,
    });
    ok++;
  } catch (e) {
    fail++;
  }
  if (++done % 40 === 0) console.log(`  …${done}/${uniq.length}  (ok ${ok} / fail ${fail})`);
}

(async () => {
  const CONC = 10;
  for (let i = 0; i < uniq.length; i += CONC) {
    await Promise.all(uniq.slice(i, i + CONC).map(fetchOne));
  }
  // fold in the procedurally-generated grasscloth weaves as their own group
  const genDir = path.join(__dirname, '..', 'public', 'textures', 'grasscloth');
  if (fs.existsSync(path.join(genDir, 'index.json'))) {
    const gen = JSON.parse(fs.readFileSync(path.join(genDir, 'index.json'), 'utf8'));
    manifest['generated'] = {
      name: 'Generated Grasscloth Weave',
      textures: gen.textures.map(t => ({ sku: 'gen-' + t.slug, name: t.name, file: `../grasscloth/${t.file}` })),
    };
  }
  for (const k of Object.keys(manifest)) manifest[k].textures.sort((a, b) => a.name.localeCompare(b.name));
  fs.writeFileSync(path.join(OUT, 'index.json'),
    JSON.stringify({ source: 'dw_unified plain-texture products', generated: new Date().toISOString(), groups: manifest }, null, 1));
  const total = Object.values(manifest).reduce((n, g) => n + g.textures.length, 0);
  console.log(`\ndone — ${total} textures across ${Object.keys(manifest).length} material groups`);
  for (const [k, g] of Object.entries(manifest)) console.log(`  ${g.name}: ${g.textures.length}`);
  console.log(`(${fail} downloads failed and were skipped)`);
})();