← back to Majilite Jewelry Cases

scripts/gen-batch.mjs

87 lines

#!/usr/bin/env node
// Build ONE jewelry-display-case render per item for: all metallics + all Novasuede.
// Reference-image-to-image via Replicate google/nano-banana (~$0.039/img). Resume-safe.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUT = path.join(ROOT, 'output');
const PRICE = 0.039;

const RT = (fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8')
  .match(/^REPLICATE_API_TOKEN=(.+)$/m) || [])[1]?.trim().replace(/"/g, '');
if (!RT) { console.error('No REPLICATE_API_TOKEN'); process.exit(1); }

const METAL_RX = /metallic|silver|pearl|foil|chrome|\bgold\b|bronze|copper|platinum|shimmer|apollo|finesse|drizzle|celestial|brushed|mica|glitter|luster|lustre|iridescent|frost|sterling|pewter|titanium|steel|glimmer|attache|beton|burnished|capricorn|chinchilla|cross-hatch|deco|echo|eclipse|farro|stardust|leaf/i;

const FIDELITY =
  'The material is the interior lining/upholstery of the display, not a backdrop. ' +
  'Photorealistic luxury jewelry catalog photography, glass-topped boutique display case, ' +
  'watches, pendants and rings arranged on the lined risers, warm high-end retail lighting, ' +
  'no text, no watermark, no logos, no hands.';

const promptFor = (it) => {
  const metal = METAL_RX.test(it.title);
  const mat = metal
    ? 'a premium METALLIC synthetic suede microfiber material'
    : 'a premium synthetic suede microfiber material';
  const crit = metal
    ? 'CRITICAL: reproduce the EXACT metallic color, shimmer/sheen and fine suede nap shown in the provided material swatch image.'
    : 'CRITICAL: reproduce the EXACT color, sheen and fine suede nap shown in the provided material swatch image.';
  return `A glass-topped boutique jewelry counter display case whose interior base and risers are lined in ${mat}. ${crit} ${FIDELITY}`;
};

const safe = s => String(s ?? 'item').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function genOne(prompt, swatchUrl) {
  for (let a = 1; a <= 4; a++) {
    const r = await fetch('https://api.replicate.com/v1/models/google/nano-banana/predictions', {
      method: 'POST',
      headers: { Authorization: `Bearer ${RT}`, 'Content-Type': 'application/json', Prefer: 'wait' },
      body: JSON.stringify({ input: { prompt, image_input: [swatchUrl], output_format: 'png' } }),
    });
    const j = await r.json();
    if (j.status === 'succeeded' && j.output) {
      const url = Array.isArray(j.output) ? j.output[0] : j.output;
      const img = await fetch(url); return Buffer.from(await img.arrayBuffer());
    }
    if (r.status === 429 || r.status >= 500 || j.status === 'starting' || j.status === 'processing') { await sleep(2500 * a); continue; }
    throw new Error(`replicate ${r.status} ${j.status || ''}: ${JSON.stringify(j.error || j).slice(0, 160)}`);
  }
  throw new Error('exhausted retries');
}

async function main() {
  const all = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/items.json'), 'utf8'));
  // union: metallic (any vendor) + all Novasuede; dedupe by sku
  const ALL = process.argv.includes('--all');
  const seen = new Set(), items = [];
  for (const x of all) {
    const key = x.sku || x.title;
    if (seen.has(key)) continue;
    if (ALL || METAL_RX.test(x.title) || x.vendor === 'Novasuede') { seen.add(key); items.push(x); }
  }
  console.log(`Target items: ${items.length}  (metallics + all Novasuede, 1 render each)`);

  let made = 0, skip = 0, fail = 0, spend = 0;
  for (let i = 0; i < items.length; i++) {
    const it = items[i];
    const dir = path.join(OUT, `${safe(it.vendor)}__${safe(it.sku || it.title)}`);
    fs.mkdirSync(dir, { recursive: true });
    const f = path.join(dir, 'glass-counter.png');
    if (fs.existsSync(f)) { skip++; continue; }
    try {
      const png = await genOne(promptFor(it), it.image_url);
      fs.writeFileSync(f, png);
      made++; spend += PRICE;
      console.log(`  [${i + 1}/${items.length}] ${it.vendor} ${it.title}  ($${spend.toFixed(2)})`);
    } catch (e) { fail++; console.log(`  ! ${it.sku}: ${e.message}`); }
    await sleep(300);
  }
  console.log(`\nDONE. made=${made} skipped=${skip} failed=${fail}  est_spend=$${spend.toFixed(2)}`);
}
main().catch(e => { console.error(e); process.exit(1); });