← 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)}.`);