← back to Wallco Ai

scripts/bulk-bg-swap.js

93 lines

#!/usr/bin/env node
// bulk-bg-swap — apply ONE texture to MANY designs in a category.
//
// Pulls all published wallco.ai designs in the named category, hits
// /api/design/:id/replace-bg with the given texture image URL (or slug),
// concurrency-3 to stay polite with Gemini. Logs progress + results.
//
// Usage:
//   node scripts/bulk-bg-swap.js --category drunk-animals --texture-url URL --texture-name "Topanga Canyon" [--limit 50] [--concurrency 3]
//   node scripts/bulk-bg-swap.js --category drunk-animals --texture-slug cream-grasscloth

'use strict';
const fs = require('fs');
const path = require('path');
const { psqlQuery } = require('../lib/db.js');

const SERVER = process.env.WALLCO_SERVER || 'http://127.0.0.1:9877';
const ARGS = process.argv.slice(2);
const arg = n => { const i = ARGS.indexOf('--' + n); return i >= 0 ? ARGS[i+1] : null; };

const CATEGORY    = arg('category');
const TEXTURE_URL = arg('texture-url');
const TEXTURE_SLUG= arg('texture-slug');
const TEXTURE_NAME= arg('texture-name') || (TEXTURE_SLUG || 'custom');
const LIMIT       = parseInt(arg('limit') || '500', 10);
const CONCURRENCY = parseInt(arg('concurrency') || '3', 10);

if (!CATEGORY) { console.error('--category required'); process.exit(2); }
if (!TEXTURE_URL && !TEXTURE_SLUG) { console.error('--texture-url OR --texture-slug required'); process.exit(2); }

function log(msg) { console.log(`[${new Date().toISOString()}] ${msg}`); }

async function swap(id) {
  const body = TEXTURE_URL
    ? { texture_image_url: TEXTURE_URL, texture_name: TEXTURE_NAME }
    : { texture_slug: TEXTURE_SLUG };
  const r = await fetch(`${SERVER}/api/design/${id}/replace-bg`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const j = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(j.error || `http ${r.status}`);
  return j;
}

async function pool(items, n, fn) {
  let i = 0;
  const next = () => (i < items.length ? items[i++] : null);
  async function worker() {
    while (true) {
      const it = next(); if (!it) return;
      try { await fn(it); }
      catch (e) { log(`  err id=${it} ${e.message.slice(0,140)}`); }
    }
  }
  await Promise.all(Array.from({ length: n }, worker));
}

(async () => {
  // Fetch ids
  const sql = `SELECT id FROM spoon_all_designs
    WHERE is_published=TRUE AND brand='wallco.ai' AND local_path IS NOT NULL
      AND category='${CATEGORY.replace(/'/g, "''")}'
    ORDER BY id DESC
    LIMIT ${LIMIT};`;
  const raw = psqlQuery(sql);
  const ids = raw.split('\n').filter(Boolean).map(s => parseInt(s, 10)).filter(Number.isFinite);
  if (!ids.length) { log(`no designs in ${CATEGORY}`); process.exit(0); }
  log(`bulk-bg-swap · category=${CATEGORY} texture=${TEXTURE_NAME} · ${ids.length} designs · conc=${CONCURRENCY}`);
  let done = 0, ok = 0, err = 0;
  const t0 = Date.now();
  const logF = path.join(__dirname, '..', 'logs', `bulk-bg-swap-${CATEGORY}-${Date.now()}.jsonl`);
  await pool(ids, CONCURRENCY, async (id) => {
    try {
      const j = await swap(id);
      ok++;
      fs.appendFileSync(logF, JSON.stringify({ ts: new Date().toISOString(), src_id: id, new_id: j.new_id, elapsed_s: j.elapsed_s, texture: TEXTURE_NAME, ok: true }) + '\n');
      log(`  ok #${id} → #${j.new_id} (${j.elapsed_s}s)`);
    } catch (e) {
      err++;
      fs.appendFileSync(logF, JSON.stringify({ ts: new Date().toISOString(), src_id: id, error: e.message.slice(0,200), ok: false }) + '\n');
      log(`  ERR #${id} ${e.message.slice(0,140)}`);
    }
    done++;
    if (done % 5 === 0 || done === ids.length) {
      const rate = done / ((Date.now() - t0) / 1000);
      log(`progress ${done}/${ids.length} · ok=${ok} err=${err} · ${rate.toFixed(2)}/s`);
    }
  });
  const wall = ((Date.now() - t0) / 1000).toFixed(1);
  log(`done · ${ok} ok · ${err} err · ${wall}s wall · log=${logF}`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });