← 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
M .gitignoreA scripts/gen-batch.mjs
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 →