← back to Wallco Ai

scripts/auto_variations.js

126 lines

#!/usr/bin/env node
/**
 * Auto-variation fan-out — after generator_tick.js produces a base design,
 * this script creates up to 5 color variations via Python+PIL hue shifts.
 *
 * Each variation:
 *   • New PNG written to data/generated/var_<parent>_<idx>.png
 *   • Triple-layer SAND, LLC watermark applied
 *   • Inserted as a new spoon_all_designs row with notes='auto-variation of #N'
 *   • Spawned for AI rating (graphic + interior designer Gemini critique)
 *
 * Usage:
 *   node scripts/auto_variations.js --parent 152
 *   node scripts/auto_variations.js --parent 152 --n 3        (cap at N)
 */
'use strict';
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');
const { execSync, spawn, spawnSync } = require('child_process');

const DB = 'dw_unified';
const PSQL_CMD = (process.platform === 'linux')
  ? `sudo -n -u postgres psql ${DB} -At -q`
  : `psql ${DB} -At -q`;
function psql(sql) { return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim(); }
function sqlStr(v) { return v == null ? 'NULL' : "'" + String(v).replace(/'/g,"''") + "'"; }

const OUT_DIR = path.join(__dirname, '..', 'data', 'generated');

// 5 variation recipes — each is a hue/sat/value shift applied to the parent PNG
const VARIATIONS = [
  { label: 'hue+60',   hue:  60, sat: 1.10, val: 1.00 },
  { label: 'hue+120',  hue: 120, sat: 1.10, val: 1.00 },
  { label: 'hue+180',  hue: 180, sat: 1.05, val: 1.00 },
  { label: 'hue-60',   hue: -60, sat: 1.00, val: 1.00 },
  { label: 'mono',     hue:   0, sat: 0.35, val: 1.05 },
];

function args() {
  const a = process.argv.slice(2);
  const o = { parent: null, n: 5 };
  for (let i = 0; i < a.length; i++) {
    if (a[i] === '--parent') o.parent = parseInt(a[++i], 10);
    if (a[i] === '--n')      o.n      = parseInt(a[++i], 10);
  }
  return o;
}

const opts = args();
if (!opts.parent) { console.error('--parent <id> required'); process.exit(2); }

const parentRow = psql(`SELECT row_to_json(t) FROM (SELECT id, local_path, category, dominant_hex FROM spoon_all_designs WHERE id=${opts.parent}) t;`);
if (!parentRow) { console.error('parent not found'); process.exit(2); }
const parent = JSON.parse(parentRow);
if (!parent.local_path || !fs.existsSync(parent.local_path)) {
  console.error('parent local_path missing:', parent.local_path); process.exit(2);
}

const variations = VARIATIONS.slice(0, Math.min(5, Math.max(1, opts.n)));

const py = `
import sys, colorsys
from PIL import Image
src, dst, hue_shift, sat_mul, val_mul = sys.argv[1], sys.argv[2], int(sys.argv[3]), float(sys.argv[4]), float(sys.argv[5])
img = Image.open(src).convert('RGB')
W, H = img.size
px = img.load()
for y in range(H):
    for x in range(W):
        r, g, b = px[x, y]
        h, l, s = colorsys.rgb_to_hls(r/255, g/255, b/255)
        h = ((h * 360 + hue_shift) % 360) / 360
        s = max(0, min(1, s * sat_mul))
        l = max(0, min(1, l * val_mul))
        r2, g2, b2 = colorsys.hls_to_rgb(h, l, s)
        px[x, y] = (int(r2*255), int(g2*255), int(b2*255))
img.save(dst, 'PNG')
print(dst)
`;

const created = [];
for (let i = 0; i < variations.length; i++) {
  const v = variations[i];
  const filename = `var_${opts.parent}_${v.label}_${Date.now()}.png`;
  const outPath = path.join(OUT_DIR, filename);
  const r = spawnSync('python3',
    ['-c', py, parent.local_path, outPath, String(v.hue), String(v.sat), String(v.val)],
    { encoding: 'utf8', timeout: 30000 });
  if (r.status !== 0) {
    console.log(JSON.stringify({event:'var_failed', label:v.label, err:r.stderr.slice(0,200)}));
    continue;
  }
  // Watermark
  try {
    spawnSync('python3', [path.join(__dirname, 'watermark.py'),
      'embed', outPath, '--out', outPath,
      '--owner', process.env.WATERMARK_OWNER || 'SAND, LLC',
      '--year', String(new Date().getFullYear())], { timeout: 20000 });
  } catch {}
  // Insert PG row
  const id = psql(`INSERT INTO spoon_all_designs
    (kind, width_in, height_in, panels, generator, prompt, seed,
     pd_source_ids, local_path, dominant_hex, category, is_published, notes)
    VALUES('seamless_tile', 24, 24, 1, 'replicate',
           ${sqlStr('auto-variation ' + v.label + ' of design #' + opts.parent)},
           0, '{}', ${sqlStr(outPath)},
           ${parent.dominant_hex ? sqlStr(parent.dominant_hex) : 'NULL'},
           ${parent.category ? sqlStr(parent.category) : 'NULL'},
           FALSE, ${sqlStr('auto-variation ' + v.label + ' of design #' + opts.parent)})
    RETURNING id;`);
  const newId = parseInt(id, 10);
  created.push({ id: newId, label: v.label, filename });
  console.log(JSON.stringify({event:'variation', parent:opts.parent, id:newId, label:v.label}));

  // Fire AI rating (detached)
  try {
    const rater = spawn('node',
      [path.join(__dirname, 'rate_design.js'), '--id', String(newId)],
      { detached: true, stdio: 'ignore', env: process.env });
    rater.unref();
  } catch {}
}

console.log(JSON.stringify({event:'done', parent:opts.parent, created:created.length, ids: created.map(x=>x.id)}));