[object Object]

← back to Majilite Jewelry Cases

Batch generator: all metallics + Novasuede jewelry cases (270 items); null-sku fix

37c9698a1dcade65972cb365e2f00521d0e7bb57 · 2026-08-10 13:49:12 -0700 · Steve Abrams

Files touched

Diff

commit 37c9698a1dcade65972cb365e2f00521d0e7bb57
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 13:49:12 2026 -0700

    Batch generator: all metallics + Novasuede jewelry cases (270 items); null-sku fix
---
 .gitignore            |  2 ++
 scripts/gen-batch.mjs | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 86 insertions(+)

diff --git a/.gitignore b/.gitignore
index 31fc746..9d89c04 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ tmp/
 *.log
 .DS_Store
 data/dump.err
+data/contact-*.jpg
+data/batch*.log
diff --git a/scripts/gen-batch.mjs b/scripts/gen-batch.mjs
new file mode 100644
index 0000000..3a4f834
--- /dev/null
+++ b/scripts/gen-batch.mjs
@@ -0,0 +1,84 @@
+#!/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 seen = new Set(), items = [];
+  for (const x of all) {
+    if (seen.has(x.sku)) continue;
+    if (METAL_RX.test(x.title) || x.vendor === 'Novasuede') { seen.add(x.sku); 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); });

← d8d8f24 Majilite/Novasuede -> jewelry-display-case render pipeline +  ·  back to Majilite Jewelry Cases  ·  Add --all render mode + gated dry-run Shopify gallery push s 8205b49 →