← back to Model Wars

gen-sprites.mjs

66 lines

// gen-sprites.mjs — SDXL combatant sprites → transparent PNGs (bg removed).
// Usage: REPLICATE_API_TOKEN=... node gen-sprites.mjs [only=name]
import { writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';

const TOKEN = process.env.REPLICATE_API_TOKEN;
if (!TOKEN) { console.error('REPLICATE_API_TOKEN required'); process.exit(1); }
const SDXL = '7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc';
const RMBG = 'a029dff38972b5fda4ec5d75d7d1cd25aeff621d2cf4946a41055d7db66b80bc'; // 851-labs/background-remover
const OUT = new URL('./public/assets/', import.meta.url);
const onlyFilter = (process.argv.find((a) => a.startsWith('only=')) || '').slice(5);

const STYLE = 'AAA fantasy game sprite, highly detailed, dramatic rim lighting, clean flat pure white background, centered, single subject, isolated, no ground shadow';
const NEG = 'multiple characters, crowd, text, watermark, busy background, scenery, landscape, frame, border, cropped, extra limbs, blurry, lowres';

const JOBS = [
  { name: 'sprite-knight', w: 832, h: 640, prompt: `a single armored medieval knight riding a barded warhorse and charging with a couched lance, full body strict side profile facing right, gleaming silver steel plate armor, ${STYLE}` },
  { name: 'sprite-archer', w: 640, h: 768, prompt: `a single medieval archer at full draw aiming a longbow to the right, full body strict side profile facing right, brown leather armor and hood, ${STYLE}` },
  { name: 'sprite-swordsman', w: 640, h: 768, prompt: `a single armored medieval knight lunging forward with a raised longsword to the right, full body strict side profile facing right, steel plate armor, ${STYLE}` },
  { name: 'sprite-dragon', w: 832, h: 704, prompt: `a colossal fearsome dragon with wings spread wide and jaws open, front three-quarter view, dark red and black scales, glowing eyes, epic boss creature, ${STYLE}` },
  { name: 'sprite-trebuchet', w: 768, h: 640, prompt: `a wooden medieval trebuchet siege engine loaded with a boulder, strict side profile facing right, ${STYLE}` },
];

async function run(version, input) {
  const start = await fetch('https://api.replicate.com/v1/predictions', {
    method: 'POST',
    headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ version, input }),
  });
  let pred = await start.json();
  if (pred.error) throw new Error(pred.error);
  while (!['succeeded', 'failed', 'canceled'].includes(pred.status)) {
    await new Promise((r) => setTimeout(r, 1500));
    pred = await fetch(pred.urls.get, { headers: { Authorization: `Bearer ${TOKEN}` } }).then((x) => x.json());
  }
  if (pred.status !== 'succeeded') throw new Error(`${pred.status} ${pred.error || ''}`);
  return Array.isArray(pred.output) ? pred.output[0] : pred.output;
}

async function gen(job) {
  const raw = await run(SDXL, {
    prompt: job.prompt, negative_prompt: NEG, width: job.w, height: job.h,
    num_outputs: 1, num_inference_steps: 35, guidance_scale: 7.5,
    scheduler: 'K_EULER', refine: 'expert_ensemble_refiner', apply_watermark: false,
  });
  const cut = await run(RMBG, { image: raw }); // transparent PNG
  const buf = Buffer.from(await (await fetch(cut)).arrayBuffer());
  await writeFile(new URL(`${job.name}.png`, OUT), buf);
  return buf.length;
}

if (!existsSync(OUT)) await mkdir(OUT, { recursive: true });
const jobs = onlyFilter ? JOBS.filter((j) => j.name.includes(onlyFilter)) : JOBS;
const COST_EACH = 0.017; // SDXL + rmbg
let done = 0;
console.log(`Generating ${jobs.length} sprites (SDXL + bg-remove) · est $${(jobs.length * COST_EACH).toFixed(2)}`);
for (const job of jobs) {
  try {
    const t = Date.now();
    const bytes = await gen(job);
    done++;
    console.log(`✓ ${job.name}.png  ${(bytes / 1024).toFixed(0)}KB  ${((Date.now() - t) / 1000).toFixed(1)}s  ($${(done * COST_EACH).toFixed(2)} spent)`);
  } catch (e) { console.error(`✗ ${job.name}: ${e.message}`); }
}
console.log(`Done. ${done}/${jobs.length} sprites · ~$${(done * COST_EACH).toFixed(2)}.`);