Files
dwroller/scripts/generate-weapon-images.js
Alex e511bff882 Add weapon images, full rule scraping, and expanded quick reference
- scripts/generate-weapon-images.js: AI image generation for weapon cards
  supporting HuggingFace FLUX.1-schnell, OpenAI DALL-E 3, and Gemini Imagen;
  images served from public/weapon-images/ via new static route in server.js
- public/cards.html: load manifest.json and show AI art on weapon cards
- scripts/scrape-rules-from-pdfs.py: PyMuPDF scraper extracting full rule
  text from all five rulebook PDFs with bold-span heading detection
- scripts/tag-and-dedup-rules.js: standalone dedup helper (JS-side grouping)
- database/routes/rulesRoutes.js: remove content truncation in search/random,
  add admin dedup endpoint (JS-side, fast), fix RulesTab to fetch full content
- src/components/RulesTab.jsx: fetch full rule content on modal open
- public/print.html: expanded quick reference with pre-gen character stat
  blocks (Sepheran + Lucian), Astartes traits sheet with correct derived SBs
  and TBs, full Critical Hit Tables for all 4 locations × 4 damage types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:50:50 +01:00

229 lines
8.8 KiB
JavaScript

#!/usr/bin/env node
/**
* generate-weapon-images.js
*
* Generates AI artwork for each Deathwatch weapon/armour card and saves
* them to public/weapon-images/.
*
* Supports two providers:
* --provider openai (DALL-E 3, ~$0.04/image)
* --provider gemini (Imagen 3, free tier available)
*
* Usage:
* GOOGLE_API_KEY=... node scripts/generate-weapon-images.js --provider gemini
* GOOGLE_API_KEY=... node scripts/generate-weapon-images.js --provider gemini --limit 3
* OPENAI_API_KEY=... node scripts/generate-weapon-images.js --provider openai --limit 3
* Add --force to regenerate already-existing images
*/
const fetch = require('node-fetch');
const fs = require('fs');
const path = require('path');
// ── CLI args ──────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const PROVIDER = (() => {
const i = args.indexOf('--provider');
return i !== -1 ? args[i + 1] : 'huggingface';
})();
const LIMIT = (() => {
const i = args.indexOf('--limit');
return i !== -1 ? parseInt(args[i + 1], 10) : Infinity;
})();
const FORCE = args.includes('--force');
// ── Config ────────────────────────────────────────────────────────────────
const OUTPUT_DIR = path.join(__dirname, '../public/weapon-images');
const MANIFEST = path.join(OUTPUT_DIR, 'manifest.json');
const WEAPONS_API = 'http://localhost:5000/api/weapons';
// Rate limits
const DELAY = {
openai: 13_000, // DALL-E 3: 5 req/min on tier-1
gemini: 4_000, // Imagen 3: 15 req/min on free tier
};
// ── Helpers ───────────────────────────────────────────────────────────────
function slug(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
function loadManifest() {
try { return JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); }
catch { return {}; }
}
function saveManifest(m) {
fs.writeFileSync(MANIFEST, JSON.stringify(m, null, 2));
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function buildPrompt(weapon) {
const cat = weapon.category;
const stats = weapon.stats || {};
const cls = stats.class || '';
const catDesc = {
'Melee Weapon': 'melee weapon held in gauntleted hands',
'Ranged Weapon': 'ranged firearm positioned for display',
'Grenade': 'grenade or explosive device, palm-sized',
'Armour': 'suit of Space Marine power armor',
'Other': 'piece of military equipment',
}[cat] || 'weapon';
return [
`Warhammer 40000 Deathwatch ${catDesc}: "${weapon.name}".`,
cls ? `Type: ${cls}.` : '',
'Dark gothic sci-fi aesthetic, ultra-detailed digital painting.',
'Dramatic atmospheric lighting, glowing sigils, battle-worn metal.',
'Centered isolated subject, black background, no text, no watermark.',
'Style: official Games Workshop concept art.',
].filter(Boolean).join(' ');
}
// ── OpenAI DALL-E 3 ───────────────────────────────────────────────────────
async function generateOpenAI(prompt) {
const key = process.env.OPENAI_API_KEY;
if (!key) throw new Error('OPENAI_API_KEY is not set');
const res = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ model: 'dall-e-3', prompt, n: 1, size: '1024x1024',
quality: 'standard', response_format: 'url' }),
});
if (!res.ok) throw new Error(`OpenAI error ${res.status}: ${await res.text()}`);
const data = await res.json();
const url = data.data[0].url;
// download PNG
const imgRes = await fetch(url);
if (!imgRes.ok) throw new Error(`Download failed: ${imgRes.status}`);
return imgRes.buffer();
}
// ── Hugging Face FLUX.1-schnell ───────────────────────────────────────────
async function generateHuggingFace(prompt) {
const key = process.env.HF_TOKEN || process.env.HUGGINGFACE_TOKEN;
if (!key) throw new Error('HF_TOKEN is not set');
const res = await fetch(
'https://router.huggingface.co/hf-inference/models/black-forest-labs/FLUX.1-schnell',
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
body: JSON.stringify({ inputs: prompt }),
}
);
if (!res.ok) throw new Error(`HuggingFace error ${res.status}: ${await res.text()}`);
return res.buffer();
}
// ── Google Gemini Imagen 3 ────────────────────────────────────────────────
async function generateGemini(prompt) {
const key = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
if (!key) throw new Error('GOOGLE_API_KEY (or GEMINI_API_KEY) is not set');
const model = 'imagen-3.0-generate-002';
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict?key=${key}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instances: [{ prompt }],
parameters: {
sampleCount: 1,
aspectRatio: '1:1',
safetyFilterLevel: 'block_only_high',
personGeneration: 'allow_adult',
},
}),
});
if (!res.ok) throw new Error(`Gemini error ${res.status}: ${await res.text()}`);
const data = await res.json();
const b64 = data.predictions?.[0]?.bytesBase64Encoded;
if (!b64) throw new Error('No image in Gemini response: ' + JSON.stringify(data));
return Buffer.from(b64, 'base64');
}
// ── Main ──────────────────────────────────────────────────────────────────
async function main() {
const generate = PROVIDER === 'openai' ? generateOpenAI
: PROVIDER === 'gemini' ? generateGemini
: generateHuggingFace;
const delayMs = DELAY[PROVIDER] ?? 2_000;
const keyVar = PROVIDER === 'openai' ? 'OPENAI_API_KEY'
: PROVIDER === 'gemini' ? 'GOOGLE_API_KEY'
: 'HF_TOKEN';
const keyVal = process.env[keyVar] || process.env.GEMINI_API_KEY || process.env.HUGGINGFACE_TOKEN;
if (!keyVal) {
console.error(`Error: ${keyVar} environment variable is not set.`);
process.exit(1);
}
console.log(`Provider: ${PROVIDER.toUpperCase()} | Limit: ${LIMIT === Infinity ? 'all' : LIMIT} | Force: ${FORCE}`);
console.log(`\nFetching weapons from ${WEAPONS_API}`);
let weapons;
try {
const res = await fetch(WEAPONS_API);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
weapons = await res.json();
} catch (e) {
console.error('Could not reach server:', e.message);
console.error('Make sure the backend is running: npm run pm2:start');
process.exit(1);
}
console.log(`Found ${weapons.length} weapons/items`);
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const manifest = loadManifest();
let generated = 0, skipped = 0, errors = 0;
for (const weapon of weapons) {
if (generated >= LIMIT) break;
const key = slug(weapon.name);
const file = `${key}.png`;
const dest = path.join(OUTPUT_DIR, file);
const webPath = `/weapon-images/${file}`;
if (!FORCE && manifest[weapon.id] && fs.existsSync(dest)) {
skipped++;
continue;
}
const prompt = buildPrompt(weapon);
console.log(`\n[${generated + 1}] ${weapon.name} (${weapon.category})`);
console.log(` prompt: ${prompt.substring(0, 90)}`);
try {
const buf = await generate(prompt);
fs.writeFileSync(dest, buf);
manifest[weapon.id] = { name: weapon.name, path: webPath, provider: PROVIDER, generated: new Date().toISOString() };
saveManifest(manifest);
console.log(` ✓ Saved → ${webPath}`);
generated++;
} catch (e) {
console.error(` ✗ Failed: ${e.message}`);
errors++;
}
// Rate-limit throttle
const isLast = weapons.indexOf(weapon) >= weapons.length - 1 || generated >= LIMIT;
if (!isLast) {
process.stdout.write(` … waiting ${delayMs / 1000}s …`);
await sleep(delayMs);
process.stdout.write('\r\x1b[2K');
}
}
console.log(`\nDone. Generated: ${generated}, Skipped: ${skipped}, Errors: ${errors}`);
if (generated > 0) console.log(`Images in: public/weapon-images/`);
}
main().catch(e => { console.error(e); process.exit(1); });