← back to Dw Marketing Reels

scripts/batch-jewelry.mjs

107 lines

#!/usr/bin/env node
// batch-jewelry.mjs — for every Majilite item, generate 3 "jewelry display case with the
// product applied" images via OpenAI gpt-image-1 (image edit; the swatch is the reference so
// the exact metallic material is applied). LOCAL-ONLY output under majilite-jewelry/<handle>/.
// Resumable ($0 to re-run — skips items that already have 3 images). Shows a running cost total.
import { mkdirSync, existsSync, writeFileSync, readFileSync, appendFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'majilite-jewelry');
const TMP = join(ROOT, 'spotlight-work', 'tmp-swatch');
mkdirSync(OUT, { recursive: true });
mkdirSync(TMP, { recursive: true });
const LOG = join(OUT, 'batch.log');
const MAN = join(OUT, 'manifest.json');
const KEY = process.env.OPENAI_API_KEY;
if (!KEY) { console.error('OPENAI_API_KEY missing'); process.exit(1); }

// gpt-image-1 token pricing ($/1e6): input text 5, input image 10, output image 40
const price = u => ((u?.input_tokens_details?.text_tokens || 0) * 5 + (u?.input_tokens_details?.image_tokens || 0) * 10 + (u?.output_tokens || 0) * 40) / 1e6;
const log = m => { const line = `[${new Date().toISOString()}] ${m}`; console.log(line); appendFileSync(LOG, line + '\n'); };

const PROMPT = 'Photorealistic luxury retail jewelry display case, studio product photography. The interior back panel and shelf lining of the case are upholstered in THIS exact metallic material shown in the reference image (keep its color and texture faithful). Elegant fine gold jewelry — rings, a necklace on a bust — displayed on stands. Soft directional lighting, shallow depth of field, premium boutique aesthetic. Vertical composition.';

async function fetchMajilite() {
  const items = [];
  for (let page = 1; page <= 40; page++) {
    const r = await fetch(`https://designerwallcoverings.com/products.json?limit=250&page=${page}`, { headers: { 'User-Agent': 'dw-jewelry-batch' } });
    if (!r.ok) break;
    const j = await r.json();
    if (!j.products || !j.products.length) break;
    for (const p of j.products) {
      if (!/majilite/i.test(p.vendor || '')) continue;
      const img = (p.images && p.images[0] && p.images[0].src) || '';
      if (img) items.push({ handle: p.handle, title: p.title, image: img.startsWith('//') ? 'https:' + img : img });
    }
    if (j.products.length < 250) break;
  }
  // de-dupe by handle
  const seen = new Set();
  return items.filter(i => !seen.has(i.handle) && seen.add(i.handle));
}

async function dl(url, dest) {
  const r = await fetch(url, { headers: { 'User-Agent': 'dw-jewelry-batch' } });
  if (!r.ok) throw new Error('img ' + r.status);
  writeFileSync(dest, Buffer.from(await r.arrayBuffer()));
}

function genThree(swatchPath) {
  // curl is the most reliable multipart path; returns parsed JSON
  const out = execFileSync('curl', [
    '-s', 'https://api.openai.com/v1/images/edits',
    '-H', `Authorization: Bearer ${KEY}`,
    '-F', 'model=gpt-image-1',
    '-F', `image[]=@${swatchPath}`,
    '-F', 'n=1', '-F', 'size=1024x1536', '-F', 'quality=medium',
    '-F', `prompt=${PROMPT}`, '--max-time', '300',
  ], { maxBuffer: 64 * 1024 * 1024 }).toString();
  return JSON.parse(out);
}

async function processItem(it, idx, total, tally) {
  const dir = join(OUT, it.handle);
  if (existsSync(join(dir, '1.png'))) {
    log(`SKIP ${idx}/${total} ${it.handle} (already has image)`); return;
  }
  mkdirSync(dir, { recursive: true });
  const swatch = join(TMP, it.handle + '.png');
  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      if (!existsSync(swatch)) await dl(it.image, swatch);
      const j = genThree(swatch);
      if (j.error) throw new Error(JSON.stringify(j.error).slice(0, 160));
      const data = j.data || [];
      if (data.length < 1) throw new Error('got ' + data.length + ' images');
      data.slice(0, 1).forEach((d, i) => writeFileSync(join(dir, `${i + 1}.png`), Buffer.from(d.b64_json, 'base64')));
      const c = price(j.usage); tally.cost += c; tally.done++;
      log(`OK   ${idx}/${total} ${it.handle}  +$${c.toFixed(3)}  running=$${tally.cost.toFixed(2)}  done=${tally.done}`);
      return;
    } catch (e) {
      log(`WARN ${idx}/${total} ${it.handle} attempt ${attempt}: ${e.message}`);
      if (attempt === 2) { tally.fail++; tally.failed.push(it.handle); log(`FAIL ${it.handle}`); }
      else await new Promise(r => setTimeout(r, 3000));
    }
  }
}

async function pool(items, size, worker) {
  let i = 0;
  const runners = Array.from({ length: size }, async () => {
    while (i < items.length) { const idx = i++; await worker(items[idx], idx + 1, items.length); }
  });
  await Promise.all(runners);
}

(async () => {
  const items = await fetchMajilite();
  log(`START batch: ${items.length} Majilite items × 1 image (concurrency 3)`);
  const tally = { cost: 0, done: 0, fail: 0, failed: [] };
  await pool(items, 3, (it, idx, total) => processItem(it, idx, total, tally));
  writeFileSync(MAN, JSON.stringify({ finished_at: new Date().toISOString(), items: items.length, generated: tally.done, failed: tally.failed, est_cost_usd: +tally.cost.toFixed(2) }, null, 2));
  log(`DONE: generated ${tally.done} items, failed ${tally.fail}, total ≈ $${tally.cost.toFixed(2)}`);
})();