← back to Patterndesignlab

scripts/build-catalog.js

231 lines

// build-catalog.js — assemble the AUTHORITATIVE seed catalog with CORRECT 1:1 design→image pairs.
// OWNED content only. Two owned sources:
//   1. the 38 curated designs in data/source-catalog.json (trusted, settlement-passed)
//   2. an expansion drawn from wallco-ai/data/designs.json — published, not-removed, seamless,
//      file-present-locally, and passing a conservative local settlement keyword screen.
// The patternbank-archive scrape is compliance-LOCKED and is NEVER read here.
// Every design's OWN local image is copied into public/assets/patterns/ named pdl-<id>.<ext>.
// $0 (local) — no paid API, no AI. Deterministic.
const fs = require('fs');
const path = require('path');

const ROOT = path.join(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'public', 'assets', 'patterns');
const CATALOG_OUT = path.join(ROOT, 'data', 'catalog.json');
const CURATED = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'source-catalog.json'), 'utf8'));

const PILOT_DIR = '/Users/macstudio3/Projects/pattern-vault/public/assets/pilot';
const WALLCO = '/Users/macstudio3/Projects/wallco-ai';
const WALLCO_DESIGNS = path.join(WALLCO, 'data', 'designs.json');
const GEN_DIR = path.join(WALLCO, 'data', 'generated');
const IMG_DIR = path.join(WALLCO, 'data', 'images');
// Supplementary OWNED local image source — the WallpapersBack fork's generated/ dir
// (settlement-passed, same wallpapersback.com provenance). Recovers masters/designs
// whose master file isn't in the wallco-ai dirs but IS on local disk here.
const FORK_GEN_DIR = '/Users/macstudio3/Projects/WallpapersBack/wallpapersback-fork/data/generated';
// OWNED masters pulled READ-ONLY from prod (Kamatera WallpapersBack fork
// /root/public-projects/wallpapersback/data/generated) into this repo's gitignored
// data/prod-masters/ dir — deepens the credible chinoiserie/stripe/other lanes whose
// masters existed only on prod. Same wallpapersback.com provenance, settlement-screened.
const PROD_MASTERS_DIR = path.join(ROOT, 'data', 'prod-masters');

// ---- canonical license terms (feeds the PDP "what you're buying" copy) ----
const TERMS = {
  digital_single_use: 'Use this pattern in ONE digital product or ONE marketing campaign — on-screen, web, app or social. Non-exclusive. No physical goods for resale, no redistribution of the source file.',
  commercial_print:   'Print this pattern on physical goods for sale (wallpaper, fabric, stationery, packaging) — unlimited print runs. Non-exclusive, worldwide. Attribution optional. No sublicensing of the raw file.',
  exclusive:          'Category-exclusive buyout — the design is retired from every other channel and licensed to you alone within your product category. Includes source files. Non-transferable.',
};
const LABELS = { digital_single_use: 'Digital single-use license', commercial_print: 'Commercial print license', exclusive: 'Exclusive license' };
const TILE_PRICES  = { digital_single_use: 149, commercial_print: 495, exclusive: 2500 };
const MURAL_PRICES = { digital_single_use: 249, commercial_print: 795, exclusive: 4500 };
function standardTiers(kind) {
  const p = (kind === 'mural' || kind === 'mural_panel') ? MURAL_PRICES : TILE_PRICES;
  return ['digital_single_use', 'commercial_print', 'exclusive'].map(t => ({
    tier: t, label: LABELS[t], priceUsd: p[t], terms: TERMS[t],
  }));
}
// Ensure every curated tier also carries usage terms (some PV-INHOUSE tiers lacked them).
function withTerms(tiers, kind) {
  const fallback = standardTiers(kind);
  if (!Array.isArray(tiers) || !tiers.length) return fallback;
  return tiers.map(t => ({
    ...t,
    label: t.label || LABELS[t.tier] || t.tier,
    terms: t.terms || TERMS[t.tier] || 'Contact us for full usage terms.',
  }));
}

// ---- image resolution + copy ----
const gen = new Set(fs.existsSync(GEN_DIR) ? fs.readdirSync(GEN_DIR) : []);
const imgset = new Set(fs.existsSync(IMG_DIR) ? fs.readdirSync(IMG_DIR) : []);
const forkGen = new Set(fs.existsSync(FORK_GEN_DIR) ? fs.readdirSync(FORK_GEN_DIR) : []);
const prodMasters = new Set(fs.existsSync(PROD_MASTERS_DIR) ? fs.readdirSync(PROD_MASTERS_DIR) : []);
function resolveWallco(filename) {
  if (!filename) return null;
  if (gen.has(filename)) return path.join(GEN_DIR, filename);
  if (imgset.has(filename)) return path.join(IMG_DIR, filename);
  if (forkGen.has(filename)) return path.join(FORK_GEN_DIR, filename); // fork fallback (recovers #138 etc.)
  if (prodMasters.has(filename)) return path.join(PROD_MASTERS_DIR, filename); // prod read-only pull (deep credible lanes)
  return null;
}
function copyImage(srcAbs, id) {
  if (!srcAbs || !fs.existsSync(srcAbs)) return null;
  const ext = path.extname(srcAbs).toLowerCase() || '.png';
  const dest = `pdl-${String(id).replace(/[^a-zA-Z0-9_-]/g, '')}${ext}`;
  fs.copyFileSync(srcAbs, path.join(OUT_DIR, dest));
  return '/assets/patterns/' + dest;
}

// ---- HARDENED settlement keyword screen -------------------------------------
// Excludes settlement-blocked motifs — birds, butterflies, bananas / banana pods,
// grapes, palm / palm-fronds, and directional-tropical-leaf patterns — across
// title AND tags AND motifs AND category, case-insensitive, word-boundary aware.
// (Word boundaries stop "napalm"/"grapefruit"-style false positives while still
//  catching the real motifs.) NO paid AI this pass; this is the deterministic
//  local gate. If a design trips it, it is EXCLUDED — better to skip than serve.
const BLOCK_RE = new RegExp(
  '\\b(' + [
    'banana', 'bananas', 'banana\\s*pod', 'banana\\s*pods',
    'grape', 'grapes',
    'bird', 'birds', 'birdie',
    'butterfly', 'butterflies',
    'parrot', 'parrots', 'toucan', 'toucans', 'macaw', 'macaws',
    'flamingo', 'flamingos', 'peacock', 'peacocks',
    'hummingbird', 'hummingbirds', 'cockatoo', 'cockatoos',
    'palm', 'palms', 'palm\\s*frond', 'palm\\s*fronds', 'frond', 'fronds',
    'tropical', 'jungle', 'rainforest',
    'monstera', 'banana\\s*leaf', 'banana\\s*leaves',
    'bird\\s*of\\s*paradise',
  ].join('|') + ')\\b', 'i');
function settlementBlocked(r) {
  const blob = [
    r.title, r.category, r.styleLine,
    Array.isArray(r.tags) ? r.tags.join(' ') : '',
    Array.isArray(r.motifs) ? r.motifs.join(' ') : '',
  ].filter(Boolean).join(' ');
  const m = BLOCK_RE.exec(blob);
  return m ? m[0] : null; // returns the matched term (truthy) or null
}

// ---- credible vs novelty classifier (drives credible-first ingestion) -------
const NOVELTY_RE = /drunk|stoned|muybridge|zoo|\bdogs?\b|designer-bugs|monkey|circus|skull/i;
const isNovelty = cat => NOVELTY_RE.test(cat || '');

// ---- wallco-ai lookup for curated numeric-id designs + expansion ----
const wallco = JSON.parse(fs.readFileSync(WALLCO_DESIGNS, 'utf8'));
const byId = new Map(wallco.map(r => [String(r.id), r]));

const out = [];
let resolved = 0, fellBack = 0, blocked = 0;
const usedIds = new Set();
const blockedLog = [];

// ---- 1) curated 38 — resolve each design's OWN image ----
for (const d of CURATED) {
  usedIds.add(String(d.id));
  // Defense-in-depth: even the "trusted" curated set is run through the hardened
  // settlement screen. If a curated design trips it, EXCLUDE it (skip, don't serve).
  const hit = settlementBlocked(d);
  if (hit) { blocked++; blockedLog.push({ id: d.id, term: hit, where: 'curated' }); continue; }
  let srcAbs = null;
  if (typeof d.img === 'string' && d.img.startsWith('/assets/pilot/')) {
    srcAbs = path.join(PILOT_DIR, path.basename(d.img));            // PV-INHOUSE pilot webp
  } else {
    const w = byId.get(String(d.id));
    if (w) srcAbs = resolveWallco(w.filename);                       // WPB numeric id → local file
  }
  const localImg = copyImage(srcAbs, d.id);
  if (!localImg) {                          // honest rule: never assign a plausible-but-wrong tile
    fellBack++;
    continue;                               // drop — its real master is gone locally; revisit in iter-3
  }
  resolved++;
  out.push({
    id: d.id, title: d.title, category: d.category, kind: d.kind || 'seamless_tile',
    dominantHex: d.dominantHex, styleLine: d.styleLine, provenance: d.provenance,
    createdAt: d.createdAt, tags: [], motifs: [],
    img: localImg,
    imgResolved: true,
    licenseTiers: withTerms(d.licenseTiers, d.kind),
    pricingStatus: d.pricingStatus || 'DRAFT',
  });
}
const curatedResolved = out.length;

// ---- 2) expansion from wallco-ai — DEPTH via credible-first ingestion --------
// Goal (iter-3): meaningfully deepen CREDIBLE pattern-design content so the
// catalog reads like a real licensing marketplace — not padded with novelty.
// Strategy: two passes over the file-present, published, not-removed,
// settlement-passed, not-already-used pool:
//   PASS A (credible)  — ingest EVERY eligible credible design (no cap): the
//                        botanical / floral / damask / chinoiserie / stripe /
//                        toile / geometric / aztec-kilim / cactus lanes.
//   PASS B (novelty)   — keep a REPRESENTATIVE sample for character, hard-capped
//                        per category so drunk-animals & friends can't flood.
const NOV_PER_CAT = 6, DRUNK_CAP = 14, DOG_CAP = 3;

const eligible = [];
for (const r of wallco) {
  if (r.is_published !== true || r.user_removed === true) continue;
  if (r.kind !== 'seamless_tile') continue;
  if (usedIds.has(String(r.id))) continue;
  if (!resolveWallco(r.filename)) continue;
  const hit = settlementBlocked(r);
  if (hit) { blocked++; blockedLog.push({ id: r.id, term: hit, where: 'expansion', category: r.category }); continue; }
  usedIds.add(String(r.id));
  eligible.push(r);
}

function pushDesign(r, credible) {
  const localImg = copyImage(resolveWallco(r.filename), r.id);
  if (!localImg) return false;
  resolved++;
  out.push({
    id: r.id, title: r.title, category: r.category, kind: 'seamless_tile',
    dominantHex: r.dominant_hex || null, styleLine: null,
    provenance: 'owned · wallpapersback published, not-removed · settlement-screened',
    createdAt: r.created_at, tags: r.tags || [], motifs: r.motifs || [],
    img: localImg, imgResolved: true, contentClass: credible ? 'credible' : 'novelty',
    licenseTiers: standardTiers('seamless_tile'), pricingStatus: 'DRAFT',
  });
  return true;
}

// PASS A — credible, uncapped, newest-first for a fresh top-of-catalog
const credibleCands = eligible.filter(r => !isNovelty(r.category))
  .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
let addedCredible = 0;
for (const r of credibleCands) if (pushDesign(r, true)) addedCredible++;

// PASS B — novelty, per-category capped round-robin for representative variety
const novGroups = {};
for (const r of eligible) if (isNovelty(r.category)) (novGroups[r.category] = novGroups[r.category] || []).push(r);
for (const c of Object.keys(novGroups)) novGroups[c].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const novNames = Object.keys(novGroups).sort();
const capFor = c => c === 'drunk-animals' ? DRUNK_CAP : (/^dogs\b/i.test(c) ? DOG_CAP : NOV_PER_CAT);
const taken = {}; novNames.forEach(c => taken[c] = 0);
let addedNovelty = 0, round = 0;
while (true) {
  let progressed = false;
  for (const c of novNames) {
    if (taken[c] >= capFor(c)) continue;
    const r = novGroups[c][taken[c]];
    if (!r) continue;
    taken[c]++; progressed = true;
    if (pushDesign(r, false)) addedNovelty++;
  }
  if (!progressed || ++round > 200) break;
}

fs.writeFileSync(CATALOG_OUT, JSON.stringify(out, null, 1));
const correct = out.filter(d => d.imgResolved).length;
const credibleTotal = out.filter(d => d.contentClass !== 'novelty' && !isNovelty(d.category)).length;
const noveltyTotal = out.length - credibleTotal;
console.log(`Catalog built: ${out.length} designs (${curatedResolved} curated + ${addedCredible} credible expansion + ${addedNovelty} novelty expansion).`);
console.log(`Curated dropped (master gone locally, NOT mis-imaged): ${fellBack}.`);
console.log(`Settlement-blocked (excluded, not served): ${blocked}.`);
if (blockedLog.length) console.log('  blocked terms:', JSON.stringify(blockedLog.slice(0, 20)));
console.log(`Content balance: ${credibleTotal} credible / ${noveltyTotal} novelty (${(100*noveltyTotal/out.length).toFixed(1)}% novelty).`);
console.log(`Correct 1:1 design→image pairs: ${correct}/${out.length} (100% by construction).`);
console.log(`Images copied into ${path.relative(ROOT, OUT_DIR)}. $0 (local).`);