← back to Majilite Jewelry Cases
scripts/gen.mjs
96 lines
#!/usr/bin/env node
// Majilite/Novasuede -> 3 jewelry-display-case renders per item.
// Reference-image-to-image via Replicate google/nano-banana (Gemini 2.5 Flash Image), ~$0.039/img.
// Resume-safe: skips renders whose PNG already exists. Shows + logs cost.
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_PER_IMG = 0.039;
const envTxt = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const RT = (envTxt.match(/^REPLICATE_API_TOKEN=(.+)$/m) || [])[1]?.trim().replace(/"/g, '');
if (!RT) { console.error('No REPLICATE_API_TOKEN'); process.exit(1); }
const args = process.argv.slice(2);
const getArg = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
const LIMIT = parseInt(getArg('--limit', '0'), 10);
const ONLY = getArg('--only', '');
const START = parseInt(getArg('--start', '0'), 10);
const MAT = 'a premium synthetic suede microfiber upholstery material';
const FIDELITY =
'CRITICAL: reproduce the EXACT color, sheen and fine suede nap/texture shown in the provided material swatch image. ' +
'The suede is the lining/upholstery of the display, not a backdrop. Photorealistic luxury jewelry catalog photography, ' +
'soft directional studio lighting, shallow depth of field, no text, no watermark, no logos, no hands.';
const SCENES = [
{ tag: 'ring-tray',
prompt: `A luxury jewelry ring display tray and ring rolls upholstered in ${MAT}, holding several fine diamond and gold rings, ` +
`photographed on a dark reflective boutique surface. ${FIDELITY}` },
{ tag: 'necklace-bust',
prompt: `An elegant open jewelry presentation case with a tall necklace bust and earring/ring stands, all lined and covered in ${MAT}, ` +
`displaying a diamond necklace and gold earrings. ${FIDELITY}` },
{ tag: 'glass-counter',
prompt: `A glass-topped boutique jewelry counter display case whose interior base and risers are lined in ${MAT}, ` +
`arranged with luxury watches and pendants, warm high-end retail setting. ${FIDELITY}` },
];
const safe = s => s.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 attempt = 1; attempt <= 4; attempt++) {
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 * attempt); continue; }
throw new Error(`replicate ${r.status} ${j.status || ''}: ${JSON.stringify(j.error || j).slice(0, 200)}`);
}
throw new Error('exhausted retries');
}
async function main() {
let items = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/items.json'), 'utf8'));
if (ONLY) items = items.filter(x => (x.sku + x.title).toLowerCase().includes(ONLY.toLowerCase()));
items = items.slice(START);
if (LIMIT > 0) items = items.slice(0, LIMIT);
const manifest = [];
let made = 0, skipped = 0, failed = 0, spend = 0;
console.log(`Items to process: ${items.length} (3 renders each -> ${items.length * 3} images)`);
for (let i = 0; i < items.length; i++) {
const it = items[i];
const dir = path.join(OUT, `${safe(it.vendor)}__${safe(it.sku)}`);
fs.mkdirSync(dir, { recursive: true });
const need = SCENES.filter(s => !fs.existsSync(path.join(dir, `${s.tag}.png`)));
if (need.length === 0) { skipped++; manifest.push({ ...it, dir }); continue; }
for (const s of need) {
try {
const png = await genOne(s.prompt, it.image_url);
fs.writeFileSync(path.join(dir, `${s.tag}.png`), png);
made++; spend += PRICE_PER_IMG;
console.log(` [${i + 1}/${items.length}] ${it.vendor} ${it.title} :: ${s.tag} ($${PRICE_PER_IMG} | run $${spend.toFixed(2)})`);
} catch (e) { failed++; console.log(` ! gen fail ${it.sku}/${s.tag}: ${e.message}`); }
await sleep(300);
}
manifest.push({ vendor: it.vendor, sku: it.sku, title: it.title, dir });
}
fs.writeFileSync(path.join(ROOT, 'data/manifest.json'), JSON.stringify(manifest, null, 2));
console.log(`\nDONE. made=${made} skipped_items=${skipped} failed=${failed} est_spend=$${spend.toFixed(2)}`);
}
main().catch(e => { console.error(e); process.exit(1); });