← back to Trending Dw

scripts/gapgen.js

270 lines

#!/usr/bin/env node
/*
 * gapgen.js — standalone generator+gate driver for the trending-dw GAP lanes.
 *
 * SELF-HOST ONLY: never touches any DB, never publishes, never deploys.
 * Generates seamless-tile candidates via Replicate SDXL, runs the FREE numeric
 * gates (seam via wallco-ai seam-defect-boxes.py, flat-color via numpy), and
 * writes seam+color-passing candidates to /tmp/gap-cand/ with a report JSON.
 * The concept + settlement vision gates are performed by the calling Claude
 * session (in-session vision) because the Gemini key is billing-capped.
 *
 * DATA-DRIVEN (fixed 2026-07-07): briefs are built from the LIVE gap item at
 * each requested id, keyed by the item's *style* (stable) — NOT hardcoded to
 * TR-### ids. The feed-rebuild step reassigns ids on every refresh, so the old
 * hardcoded per-id BRIEFS silently mis-targeted paid generation (all 16 briefs
 * pointed at trends that no longer lived at those ids). Resolving against
 * data/bestsellers.json at run time makes that drift impossible: an id that is
 * no longer a gap (has an image) is refused, not generated for.
 *
 * Usage: node gapgen.js <id> [<id> ...] [--rolls N]
 *   ids must be current GAP items in data/bestsellers.json (needsGeneration, no image).
 */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const ROOT = '/Users/macstudio3/Projects/trending-dw';
const WALLCO = '/Users/macstudio3/Projects/wallco-ai';
const VENV_PY = `${ROOT}/.gapvenv/bin/python`;
const SEAM_PY = `${WALLCO}/scripts/seam-defect-boxes.py`;
const CAND_DIR = '/tmp/gap-cand';
const REPORT = '/tmp/gap-report.json';
const LEDGER = '/tmp/gap-cost.json';

// Replicate model versions (from wallco-ai/scripts/generate_designs.js)
const TILEABLE_SDXL = 'ce888cbe17a7c04d4b9c4cbd2b576715d480c55b2ba8f9f3d33f2ad70a26cd99'; // circular-pad seamless
const STABILITY_SDXL = '7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc';
const GEN_COST = 0.014; // conservative $/image estimate for SDXL on Replicate

function loadToken() {
  const m = fs.readFileSync(`${WALLCO}/.env`, 'utf8').match(/^\s*REPLICATE_API_TOKEN\s*=\s*(\S+)/m);
  if (!m) throw new Error('REPLICATE_API_TOKEN not found');
  return m[1].replace(/^["']|["']$/g, '');
}
const TOKEN = loadToken();

// ---- palette / texture / motif briefs -------------------------------------
const SEAM_BASE = `Seamless tileable wallpaper repeat — the motif continues uninterrupted across every tile edge, elements touching the right edge resume from the left and the top resumes from the bottom, one continuous pattern that tiles infinitely with no visible seam. Consistent motif density across the entire tile including all four edges and corners.`;
const LUX_BASE = `Luxury designer wallcovering, archival gallery quality, hand-screen-printed flat solid ink colors, painted directly on a real natural TEXTURE ground with the weave, slubs and flecks subtly showing through the ink. No text, no lettering, no watermark, no signature, no border, no frame, no picture frame.`;
const NEG_BASE = `visible seam, seam line, edge mismatch, abrupt cutoff at tile border, single large centered hero motif, isolated motif on blank empty background, ghost layer, faded duplicate behind, drop shadow, embossed 3D relief, photographic depth, neon, fluorescent, day-glo, rainbow, garish, text, letters, words, numbers, watermark, signature, url, border, frame, picture frame, gilded frame, cartouche, low quality, jpeg artifact, blurry mush`;

function P(motif, texture) {
  return `${motif}. ${SEAM_BASE} ${LUX_BASE.replace('TEXTURE', texture)}`;
}

// STYLE-KEYED templates (stable across feed rebuilds). Each maps a trend `style`
// to a luxury tone-on-tone brief; color/hex come from the LIVE item at run time.
// `motif(color,hex)` returns the pattern description (wrapped by P()); `raw` is
// for styles that need a bespoke prompt (e.g. ombre uses a silk-sheen ground).
// TONE-ON-TONE low-contrast palettes match the house aesthetic AND lower the
// local-contrast floor so the seam metric (adjacent-strip ΔE) can reach PASS.
const STYLE_TEMPLATES = {
  'Texture-Effect': { texture:'grasscloth', mode:'texture', cap:5, // 3→5 (2026-07-07): a real woven natural-fiber texture legitimately carries 4-5 close tonal shades (ground+weave+slub shadows); cap 3 rejected genuine textures as if they were multi-color motifs
    motif:(c,h)=>`Tonal tactile natural-fiber texture — woven grasscloth and linen weave with subtle slubs and plaster-like tactile grain, tone-on-tone with no distinct motif, warm ${c} monochrome, dominant ${c} ${h}` },
  'Retro Geometric': { texture:'linen', mode:'texture', cap:4,
    motif:(c,h)=>`Tone-on-tone 1970s retro geometric repeat — interlocking arches, circles and mod grid shapes in a dense flat op-art pattern, soft closely-related ${c} tones, low contrast, dominant ${c} ${h}` },
  'Geometric': { texture:'linen', mode:'texture', cap:4,
    motif:(c,h)=>`Tone-on-tone geometric repeat — clean interlocking geometry in a dense flat allover pattern, soft closely-related ${c} tones, low contrast, dominant ${c} ${h}` },
  'Art-Deco Geometric': { texture:'mica', mode:'texture', cap:4,
    motif:(c,h)=>`Tone-on-tone Art Deco geometric repeat — radiating sunburst fans and stepped chevrons in soft low-contrast ${c} tones, 1920s deco style, gentle tonal contrast, dominant ${c} ${h}` },
  'Retro Psychedelic': { texture:'linen', mode:'texture', cap:4,
    motif:(c,h)=>`Tone-on-tone 1970s psychedelic wavy swirl repeat — undulating groovy melting waves in a dense allover retro pattern, soft closely-related ${c} tones, low contrast, dominant ${c} ${h}` },
  'Gingham / Check': { texture:'linen', mode:'texture', cap:4,
    motif:(c,h)=>`Tone-on-tone woven gingham check — even crosshatch check weave in soft closely-related ${c} tones, flat matte, low contrast, dominant ${c} ${h}` },
  'Boho': { texture:'jute', mode:'texture', cap:5,
    motif:(c,h)=>`Tone-on-tone boho mudcloth folk motif — hand-drawn tribal diamonds, dashes and stitch-marks in an allover mudcloth repeat, soft closely-related ${c} tones, low contrast, artisan feel, dominant ${c} ${h}` },
  'Paisley / Nomadic': { texture:'silk', mode:'texture', cap:5,
    motif:(c,h)=>`Tone-on-tone ornate paisley boteh nomadic motif — teardrop paisleys with folk-embroidery detailing in a dense allover repeat, soft closely-related ${c} tones, low contrast, heritage feel, dominant ${c} ${h}` },
  'Animal-Skin Print': { texture:'linen', mode:'motif', cap:5,
    motif:(c,h)=>`Elegant leopard/ocelot animal-SKIN spot texture — soft painterly rosette markings densely scattered allover in a cohesive muted ${c} palette, NOT whole animals, just the pelt marking pattern, gentle but clearly visible contrast, dominant ${c} ${h}` },
  'Damask': { texture:'silk', mode:'texture', cap:4,
    motif:(c,h)=>`Tone-on-tone classic damask repeat — ogee framework with stylized acanthus and scrollwork fill in a dense allover damask, soft closely-related ${c} tones, low contrast, heritage luxe, dominant ${c} ${h}` },
  'Tropical': { texture:'grasscloth', mode:'texture', cap:4, foliage:true,
    settlementNote:'risk lane — NO birds/butterflies/bananas/grapes; branches acceptable',
    motif:(c,h)=>`Whisper tone-on-tone tropical foliage — small fern fronds and slender branches in an EXTREMELY dense edge-to-edge allover fill, near-monochrome soft ${c} with only faint tonal shading, minimal contrast, flat matte, calm botanical, NO birds NO butterflies NO bananas NO grapes, dominant ${c} ${h}` },
  'Botanical': { texture:'grasscloth', mode:'texture', cap:5, foliage:true,
    settlementNote:'foliage lane — NO birds/butterflies/bananas/grapes; branches acceptable',
    motif:(c,h)=>`Tone-on-tone botanical repeat — dense allover leaves, ferns and visible branches, soft closely-related ${c} tones, flat matte, calm, NO birds NO butterflies NO bananas NO grapes, dominant ${c} ${h}` },
  'Heritage Floral': { texture:'linen', mode:'texture', cap:5, foliage:true,
    settlementNote:'foliage lane — NO birds/butterflies/bananas/grapes',
    motif:(c,h)=>`Tone-on-tone heritage floral repeat — trailing stylized blooms and leafy stems in a dense allover chintz repeat, soft closely-related ${c} tones, low contrast, NO birds NO butterflies NO bananas NO grapes, dominant ${c} ${h}` },
  'Novelty Conversational': { texture:'linen', mode:'motif', cap:5,
    motif:(c,h)=>`Tone-on-tone whimsical conversational motif — small charming repeating icons in a dense allover repeat, soft closely-related ${c} tones, low contrast, flat matte, NO birds NO butterflies, dominant ${c} ${h}` },
  'Mushroom / Goblincore': { texture:'cork', mode:'motif', cap:6,
    settlementNote:'foliage-adjacent — NO birds/butterflies',
    motif:(c,h)=>`Whisper tone-on-tone woodland mushrooms — small toadstool caps and tiny ferns in a dense edge-to-edge allover fill, near-monochrome soft ${c} with faint tonal shading, minimal contrast, flat matte, NO butterflies NO birds, dominant ${c} ${h}` },
  'Abstract Blur': { texture:'silk', mode:'texture', cap:10, noBlurNeg:true,
    raw:(c,h)=>`Soft ombre abstract blur — dreamy mottled watercolor wash of closely-related ${c} tones drifting cloud-like across the surface, gentle low-contrast tonal transitions with no hard edges, no distinct motif, serene atmospheric, dominant ${c} ${h}. ${SEAM_BASE} Luxury designer wallcovering, archival quality, painted on a real natural silk ground with a subtle sheen. No text, no watermark, no border, no frame.` },
  _default: { texture:'linen', mode:'texture', cap:5,
    motif:(c,h)=>`Tone-on-tone luxury wallcovering pattern in soft closely-related ${c} tones, dense allover repeat, flat matte, low contrast, no distinct hero motif, NO birds NO butterflies NO bananas NO grapes, dominant ${c} ${h}` },
};
// styles with no template of their own map onto the closest one
const STYLE_ALIAS = { 'Dark Floral':'Heritage Floral', 'Ditsy Micro-Print':'Novelty Conversational', 'Scenic Mural':'Botanical' };

function briefFor(item) {
  const style = STYLE_ALIAS[item.style] || item.style;
  const t = STYLE_TEMPLATES[style] || STYLE_TEMPLATES._default;
  const color = item.color || 'neutral';
  const hex = (typeof item.dominantHex === 'string' && /^#[0-9a-f]{6}$/i.test(item.dominantHex)) ? item.dominantHex : '#9a9488';
  return {
    id: item.id, style: item.style, color, hex, cap: t.cap, foliage: !!t.foliage,
    mode: t.mode, noBlurNeg: !!t.noBlurNeg, settlementNote: t.settlementNote || null,
    concept: item.genBrief || `${item.style} in ${color}`,
    prompt: t.raw ? t.raw(color, hex) : P(t.motif(color, hex), t.texture),
  };
}

// ---- Replicate gen --------------------------------------------------------
function negFor(b) {
  let n = NEG_BASE;
  if (b.noBlurNeg) n = n.replace(', blurry mush', '').replace('drop shadow, ', '');
  n += `, more than ${b.cap + 1} distinct colors`;
  return n;
}

function genReplicate(brief, seed, outPath, useStability) {
  const version = useStability ? STABILITY_SDXL : TILEABLE_SDXL;
  // TILEABLE model (pwntus/material-diffusion-sdxl): circular-conv seamless is
  // baked in — but the SDXL refiner is a SEPARATE non-circular pass that
  // re-introduces edge seams, so refine MUST be 'no_refiner'. Native tiling
  // size is 768; DDIM scheduler, ~45 steps.
  const input = useStability
    ? { prompt: brief.prompt, negative_prompt: negFor(brief), width: 1024, height: 1024,
        num_inference_steps: 30, guidance_scale: 7.5, seed, refine: 'expert_ensemble_refiner', apply_watermark: false }
    : { prompt: brief.prompt, negative_prompt: negFor(brief), width: 768, height: 768,
        num_inference_steps: 45, guidance_scale: 7.5, scheduler: 'DDIM', seed,
        refine: 'no_refiner', apply_watermark: false };
  const body = { version, input };
  const submit = execSync(`curl -sf -m 30 -H 'Authorization: Bearer ${TOKEN}' -H 'Content-Type: application/json' -X POST 'https://api.replicate.com/v1/predictions' -d @-`,
    { input: JSON.stringify(body), encoding: 'utf8' });
  const pred = JSON.parse(submit);
  if (!pred.id) throw new Error('no prediction id: ' + submit.slice(0, 200));
  const start = Date.now(); let final;
  while (Date.now() - start < 4 * 60000) {
    execSync('sleep 3');
    const p = JSON.parse(execSync(`curl -sf -m 10 -H 'Authorization: Bearer ${TOKEN}' 'https://api.replicate.com/v1/predictions/${pred.id}'`, { encoding: 'utf8' }));
    if (p.status === 'succeeded') { final = p; break; }
    if (p.status === 'failed' || p.status === 'canceled') throw new Error(`${p.status}: ${JSON.stringify(p.error || '').slice(0,200)}`);
  }
  if (!final) throw new Error('timeout');
  const url = Array.isArray(final.output) ? final.output[0] : final.output;
  if (!url) throw new Error('no image url');
  execSync(`curl -sf -m 60 -L -o ${JSON.stringify(outPath)} ${JSON.stringify(url)}`);
  if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 1000) throw new Error('download failed');
  const predict = (final.metrics && final.metrics.predict_time) || 0;
  return predict;
}

// ---- free gates -----------------------------------------------------------
function seamGate(png) {
  const out = execSync(`${VENV_PY} ${SEAM_PY} --path ${JSON.stringify(png)}`, { encoding: 'utf8' });
  const j = JSON.parse(out);
  return { verdict: j.verdict, overall_max: Math.round(j.scores.overall_max * 100) / 100,
    edges_max: Math.round(j.scores.edges_max * 100) / 100, mids_max: Math.round(j.scores.mids_max * 100) / 100 };
}
function colorGate(png, pct = 0.03) {
  const out = execSync(`${VENV_PY} - ${JSON.stringify(png)} ${pct}`, { encoding: 'utf8', input: `
import sys, numpy as np
from PIL import Image
im = np.asarray(Image.open(sys.argv[1]).convert('RGB'))
b = (im >> 4).reshape(-1, 3)
_, c = np.unique(b, axis=0, return_counts=True)
print(int((c >= c.sum() * float(sys.argv[2])).sum()))
` });
  const n = parseInt(out.trim(), 10);
  if (isNaN(n)) throw new Error('colorGate bad output: ' + out.slice(0, 80));
  return n;
}

// ---- main -----------------------------------------------------------------
function loadJSON(p, d) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return d; } }
const args = process.argv.slice(2);
let rolls = 2;
const ri = args.indexOf('--rolls');
if (ri >= 0) { rolls = parseInt(args[ri + 1], 10); args.splice(ri, 2); }
if (isNaN(rolls) || rolls < 1) { console.error('--rolls requires a positive integer'); process.exit(1); }
// --dry-run: resolve ids + build briefs and print them, but generate NOTHING
// (no Replicate call, no spend). Use to validate targeting before paying.
const di = args.indexOf('--dry-run');
const DRY = di >= 0; if (DRY) args.splice(di, 1);

// DATA-DRIVEN resolution: each requested id is looked up in the LIVE feed and a
// brief is built from that item's *current* style/color/hex. An id that isn't in
// the feed, or that already has an image (not a gap), is refused — never
// generated for. This is what makes brief↔trend drift impossible.
const BEST = path.join(ROOT, 'data', 'bestsellers.json');
const liveItems = (loadJSON(BEST, { items: [] }).items) || [];
const byId = new Map(liveItems.map(i => [i.id, i]));
const targets = [];
for (const a of args) {
  const it = byId.get(a);
  if (!it) { console.log(`[SKIP] ${a}: not in current feed`); continue; }
  if (it.image) { console.log(`[SKIP] ${a}: already imaged (${it.style}) — not a gap`); continue; }
  targets.push(briefFor(it));
}
if (!targets.length) {
  console.error('no current gap ids to generate. pass TR-ids that are GAPS in data/bestsellers.json (needsGeneration, no image).');
  process.exit(1);
}

if (DRY) {
  console.log(`[DRY-RUN] ${targets.length} target(s) resolved from live feed — NO generation, $0 spent:\n`);
  for (const b of targets) {
    console.log(`${b.id}  ${b.style} / ${b.color} ${b.hex}  cap=${b.cap}${b.foliage?' foliage':''}${b.settlementNote?'  ⚠ '+b.settlementNote:''}`);
    console.log(`  prompt: ${b.prompt.slice(0, 160)}…\n`);
  }
  process.exit(0);
}

// COST GUARD (Steve 2026-07-07): generation must default to LOCAL models, not paid
// Replicate. gapgen still calls Replicate, so refuse to spend unless explicitly opted
// in. NEXT TASK: port wallco-ai's comfy path (comfyCurl → COMFY_URL/prompt) so
// GEN_BACKEND=comfy generates on Mac1 ComfyUI for $0.
const BACKEND = process.env.GEN_BACKEND || 'comfy';
if (BACKEND !== 'replicate') {
  console.error(`[COST GUARD] refusing to spend on paid Replicate by default (GEN_BACKEND=${BACKEND}).
Local ComfyUI generation ($0) is not yet wired into gapgen — that's the next task.
To knowingly use paid Replicate (~$${GEN_COST}/img), re-run with: GEN_BACKEND=replicate node scripts/gapgen.js ${args.join(' ')} --rolls ${rolls}
(--dry-run works free without a backend.)`);
  process.exit(2);
}

fs.mkdirSync(CAND_DIR, { recursive: true });
const ledger = loadJSON(LEDGER, { total: 0, gens: 0 });
const report = loadJSON(REPORT, {});
const CAP = 3.0;

for (const b of targets) {
  const id = b.id;
  report[id] = report[id] || { id, style: b.style, color: b.color, hex: b.hex, cap: b.cap, candidates: [], attempts: 0 };
  for (let r = 0; r < rolls; r++) {
    if (ledger.total + GEN_COST > CAP) { console.log(`[BUDGET] would exceed $${CAP.toFixed(2)} — stopping. total=$${ledger.total.toFixed(4)}`); break; }
    const seed = Math.floor(Math.random() * 1e9);
    const out = `${CAND_DIR}/${id}__s${seed}.png`;
    let predict = 0, err = null, stability = false;
    try { predict = genReplicate(b, seed, out, false); }
    catch (e) { // fall back to stability SDXL if tileable version errors
      try { predict = genReplicate(b, seed, out, true); stability = true; }
      catch (e2) { err = e2.message; }
    }
    ledger.gens++; ledger.total += GEN_COST; report[id].attempts++;
    if (err) { console.log(`${id} roll ${r+1}: GEN FAILED (${err})  [$${GEN_COST.toFixed(3)} spent anyway=no, skip]`); ledger.total -= GEN_COST; ledger.gens--; continue; }
    // Gate the RAW tileable-model output — it tiles by construction (circular
    // conv) and is artifact-free. make_tileable post-processing only ADDS
    // banding/smear artifacts, so we no longer use it on the default path.
    let seam, colors;
    try { seam = seamGate(out); colors = colorGate(out); }
    catch (e) { console.log(`${id} roll ${r+1}: GATE ERROR ${e.message}`); continue; }
    const seamOK = seam.overall_max <= 5.0;
    const colorOK = colors <= b.cap;
    const rec = { seed, png: out, seam: seam.verdict, overall_max: seam.overall_max, edges_max: seam.edges_max, mids_max: seam.mids_max, colors, seamOK, colorOK, stability, predict: Math.round(predict*100)/100 };
    report[id].candidates.push(rec);
    console.log(`${id} roll ${r+1}: seam=${seam.verdict}(overall ${seam.overall_max}, edges ${seam.edges_max}, mids ${seam.mids_max}) colors=${colors}/${b.cap} ${seamOK&&colorOK?'✅ PASS free-gates':'❌'} seed=${seed}${stability?' [stability]':''}  | gen $${GEN_COST.toFixed(3)}  running $${ledger.total.toFixed(4)}`);
  }
}
fs.writeFileSync(REPORT, JSON.stringify(report, null, 2));
fs.writeFileSync(LEDGER, JSON.stringify(ledger, null, 2));
console.log(`\n[LEDGER] gens=${ledger.gens}  Replicate total=$${ledger.total.toFixed(4)}  (cap $${CAP.toFixed(2)})`);
console.log(`[REPORT] ${REPORT}`);