← back to Model Wars
gen-art.mjs
70 lines
// gen-art.mjs — generate MODEL WARS art with Stable Diffusion XL (Replicate).
// Champions as armored fantasy champions + cinematic battle backdrops.
// Usage: REPLICATE_API_TOKEN=... node gen-art.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'; // stability-ai/sdxl
const OUT = new URL('./public/assets/', import.meta.url);
const onlyFilter = (process.argv.find((a) => a.startsWith('only=')) || '').slice(5);
const NEG = 'text, watermark, signature, logo, blurry, lowres, deformed, extra limbs, cartoon, flat, ugly, jpeg artifacts, modern clothing, photograph of real person';
const PORTRAIT = 'epic AAA fantasy game character portrait, ornate polished plate armor, intricate detail, dramatic volumetric cinematic lighting, rim light, dark moody background, liquid glass reflections, gold and silver trim, embers, artstation, unreal engine 5, octane render, 8k';
const SCENE = 'epic cinematic medieval fantasy environment, AAA game splash art, dramatic volumetric lighting, deep blacks, gold light, fire embers and smoke, atmospheric depth of field, artstation, unreal engine 5, 8k, no people in foreground';
const JOBS = [
// champions — 768x768 square portraits
{ name: 'champ-gpt', w: 768, h: 768, prompt: `The Emerald Knight, a noble knight in emerald-green enameled plate armor with a glowing emerald gemstone on the breastplate, emerald aura, ${PORTRAIT}` },
{ name: 'champ-claude', w: 768, h: 768, prompt: `The Golden Scholar, a wise knight-scholar in radiant golden filigree armor over scholarly robes, holding an ancient tome, warm golden glow, ${PORTRAIT}` },
{ name: 'champ-gemini', w: 768, h: 768, prompt: `The Celestial Mage, an ethereal mage in star-flecked azure-blue robes and silver circlet, cosmic nebula magic swirling, celestial blue glow, ${PORTRAIT}` },
{ name: 'champ-grok', w: 768, h: 768, prompt: `The Black Knight, a menacing warrior in obsidian-black spiked plate armor with sharp silver edges, red visor glow, ominous, ${PORTRAIT}` },
{ name: 'champ-deepseek', w: 768, h: 768, prompt: `The Eastern Strategist, a calm master strategist in lacquered violet and jade eastern-inspired armor with a war fan, tactical, violet glow, ${PORTRAIT}` },
{ name: 'champ-llama', w: 768, h: 768, prompt: `The Crimson Ranger, an agile ranger in crimson leather and a red hooded cloak, drawing a longbow, forest at dusk, crimson glow, ${PORTRAIT}` },
// battle backdrops — 1024x576 wide
{ name: 'scene-joust', w: 1024, h: 576, prompt: `a grand medieval tournament jousting arena, long wooden tilt barrier down the center, colorful heraldic banners, crowded timber grandstands, dusk sky, ${SCENE}` },
{ name: 'scene-archery', w: 1024, h: 576, prompt: `a medieval archery range in a castle courtyard, several large round straw archery targets with painted rings on the right, banners, morning light, ${SCENE}` },
{ name: 'scene-siege', w: 1024, h: 576, prompt: `an epic castle siege battlefield, two great stone castles facing each other across a scarred field, trebuchets, flaming projectiles, smoke and debris, ${SCENE}` },
{ name: 'scene-duel', w: 1024, h: 576, prompt: `a torchlit medieval stone dueling arena, circular sand floor, flickering wall torches, dark stone columns, tense atmosphere, ${SCENE}` },
{ name: 'scene-dragon', w: 1024, h: 576, prompt: `a colossal dragon's lair inside a vast glowing cavern, molten lava cracks, hoard of gold, ember-filled air, ominous scale, ${SCENE}` },
];
async function gen(job) {
const start = await fetch('https://api.replicate.com/v1/predictions', {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ version: SDXL, input: {
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,
} }),
});
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(`${job.name}: ${pred.status} ${pred.error || ''}`);
const url = Array.isArray(pred.output) ? pred.output[0] : pred.output;
const buf = Buffer.from(await (await fetch(url)).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.011; // ~SDXL on Replicate
let done = 0;
console.log(`Generating ${jobs.length} images via SDXL · est $${(jobs.length * COST_EACH).toFixed(2)} total`);
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} images · ~$${(done * COST_EACH).toFixed(2)} total.`);