← back to Wallco Ai

scripts/bulk-fix-category.js

99 lines

#!/usr/bin/env node
// bulk-fix-category — run the 5-layer-enforced fix-design pipeline on every
// published design in a category. Pure PIL, NO Gemini, ~200ms per design.
//
// 5-layer enforcement honored:
//   1. Calls fix-design.js (canonical script)
//   2. Which uses opacity-snap algorithm (preserve composition)
//   3. Never calls /smart-fix?legacy=1 or /regenerate-reverse
//   4. Tags each result with generator='pil-fix-design' (caught by status-browser redo-flag regex as SAFE)
//   5. Logs every fix to data/fix-decisions.jsonl via the appendFixEvent path
//
// Usage:
//   node scripts/bulk-fix-category.js --category drunk-animals
//   node scripts/bulk-fix-category.js --category drunk-animals --limit 10 --concurrency 4 --k 4
//   node scripts/bulk-fix-category.js --category drunk-animals --dry-run

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

const ARGS = process.argv.slice(2);
const arg = (n, d) => { const i = ARGS.indexOf('--' + n); return i >= 0 ? ARGS[i+1] : d; };

const CATEGORY    = arg('category');
const LIMIT       = parseInt(arg('limit', '500'), 10);
const CONCURRENCY = Math.min(8, Math.max(1, parseInt(arg('concurrency', '4'), 10)));
const K           = parseInt(arg('k', '4'), 10);
const CROP        = parseFloat(arg('crop', '1.0'));
const DRY         = ARGS.includes('--dry-run');

if (!CATEGORY) { console.error('--category required'); process.exit(2); }

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

// Pull IDs from PG
const sql = `SELECT id FROM spoon_all_designs
  WHERE category='${CATEGORY.replace(/'/g, "''")}'
    AND is_published=TRUE
    AND user_removed IS NOT TRUE
    AND brand='wallco.ai'
    AND local_path IS NOT NULL
  ORDER BY id DESC
  LIMIT ${LIMIT};`;
const ids = psqlQuery(sql).split('\n').filter(Boolean).map(s => parseInt(s, 10)).filter(Number.isFinite);
log(`bulk-fix-category · ${CATEGORY} · k=${K} crop=${CROP} conc=${CONCURRENCY} · ${ids.length} designs`);

if (DRY) {
  log('DRY-RUN — would run fix-design.js on:');
  for (const id of ids.slice(0, 10)) log(`  #${id}`);
  if (ids.length > 10) log(`  ... and ${ids.length - 10} more`);
  process.exit(0);
}

// Concurrency pool — spawn N parallel fix-design.js processes
const FIX_SCRIPT = path.join(__dirname, 'fix-design.js');
let cursor = 0, ok = 0, errs = 0;
const t0 = Date.now();

function nextOne() {
  if (cursor >= ids.length) return null;
  return ids[cursor++];
}

async function worker(workerIdx) {
  while (true) {
    const id = nextOne();
    if (id === null) return;
    await new Promise((resolve) => {
      const child = spawn('node', [FIX_SCRIPT, '--id', String(id), '--k', String(K), '--crop', String(CROP)], {
        stdio: ['ignore', 'pipe', 'pipe'],
      });
      let out = ''; let err = '';
      child.stdout.on('data', d => out += d);
      child.stderr.on('data', d => err += d);
      child.on('close', (code) => {
        const done = ok + errs + 1;
        if (code === 0) {
          ok++;
          const match = out.match(/inserted #(\d+)/);
          log(`  [w${workerIdx}] ok #${id}${match ? ` → #${match[1]}` : ''}  (${done}/${ids.length})`);
        } else {
          errs++;
          const firstErr = (err || out).split('\n').filter(Boolean)[0] || `exit ${code}`;
          log(`  [w${workerIdx}] ERR #${id}: ${firstErr.slice(0, 140)}  (${done}/${ids.length})`);
        }
        resolve();
      });
    });
  }
}

(async () => {
  await Promise.all(Array.from({ length: CONCURRENCY }, (_, i) => worker(i + 1)));
  const wall = ((Date.now() - t0) / 1000).toFixed(1);
  log(`done · ${ok} ok · ${errs} errors · ${wall}s wall · ${(ok / parseFloat(wall)).toFixed(1)}/s`);
})();