Diagnosed rather than guessed. The martlet failed three times over ninety minutes while the gryphon succeeded in the same background run, so it was neither the service nor the account nor the window. Regenerating it with the single word "bare" replaced and nothing else changed brought it back immediately. It comes from armourWords at naturalArmour 0 — Midjourney reads it as nudity — and thirteen creatures carried it, every one of them a creature with no natural armour. That band says "thin-skinned, nothing between it and a blow" now. BANNED returned its hits as `warnings` for the caller to print. A warning in a log nobody reads is precisely what let this stall three times, because the failure it predicts is a job that never arrives and never fails, which looks exactly like the service being slow. promptFor throws now, as it does for an over-long negative list, and says why the refusal is invisible. All 204 specs still build; a planted "corpse" is refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
575 lines
28 KiB
JavaScript
575 lines
28 KiB
JavaScript
/**
|
|
* The illustrated-portrait queue — the half of the art pipeline that can be automated.
|
|
*
|
|
* WHY THIS IS NOT mj-gen. The Mac has a bridge that posts `/imagine` to Discord using a
|
|
* user token and downloads the grid. It cannot be ported here and should not be: it is a
|
|
* self-bot, Midjourney's terms as of 27 May 2026 forbid automated access outright, and
|
|
* the memory note recording its own behaviour says Discord began silently dropping its
|
|
* interactions in July 2026 — which is what enforcement looks like from the inside. There
|
|
* is no official Midjourney API to move to.
|
|
*
|
|
* So this tool does everything EXCEPT talk to Discord. That turns out to be most of the
|
|
* work. What actually costs time is not pasting a prompt; it is writing sixty prompts
|
|
* that agree with sixty statblocks, remembering which ones are done, and renaming
|
|
* `dave_a_woodcut_portrait_2f9c-u1.png` to something the pack builder will find.
|
|
*
|
|
* node tools/mj-queue.mjs list what has illustration, what does not
|
|
* node tools/mj-queue.mjs prompt <key> print one prompt
|
|
* node tools/mj-queue.mjs next take the next creature: print + clipboard
|
|
* node tools/mj-queue.mjs take <key> take a specific one
|
|
* node tools/mj-queue.mjs file file the newest inbox image to the taken one
|
|
* node tools/mj-queue.mjs watch file automatically as images arrive
|
|
* node tools/mj-queue.mjs sheet write a markdown prompt sheet
|
|
* node tools/mj-queue.mjs status what is currently taken
|
|
*
|
|
* The loop is: `next` (prompt lands on your clipboard) -> paste into Discord yourself ->
|
|
* save the upscale -> `file`. Or leave `watch` running and just save; it files them as
|
|
* they land. One at a time, deliberately — the memory note is explicit that two
|
|
* concurrent jobs in one channel steal each other's results.
|
|
*
|
|
* PROMPTS ARE DERIVED, NOT WRITTEN. Every descriptive clause comes out of the creature's
|
|
* own spec — its anatomy from LOCATION_TABLES, its scale from SIZ, its build from STR
|
|
* against SIZ, its plating from naturalArmour. Ask for a Cadence and the prompt says
|
|
* five separate bodies and no head, because its location table has five entries and no
|
|
* head, and it will say that whatever anybody later types into content.mjs. This is the
|
|
* same argument rules.mjs makes about formulas, applied to art direction.
|
|
*/
|
|
import { writeFile, readFile, copyFile, readdir, stat, unlink, mkdir } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { LOCATION_TABLES } from "../anatomy.mjs";
|
|
import { WEAPONS } from "./content.mjs";
|
|
import { collectSpecs } from "./all-specs.mjs";
|
|
import { eraOf, roleTagOf, MONSTER_TAGS, carriesNothingManufactured } from "./make-portraits.mjs";
|
|
|
|
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
|
|
const ART = path.join(ROOT, "art", "portraits");
|
|
const STATE = path.join(ROOT, ".mj-inflight");
|
|
|
|
const WEAPON = new Map(WEAPONS.map(w => [w.key, w]));
|
|
const RASTER = ["png", "webp", "jpg", "jpeg"];
|
|
|
|
/* ---------------------------------------------------------------- style */
|
|
|
|
/**
|
|
* House style, as tokens, in the same shape THROUGH_TRAIN_ART.md uses — so `sheet` emits
|
|
* something tools/gen-art.mjs can already parse, and a generated sheet is a document
|
|
* rather than a thing only this script understands.
|
|
*
|
|
* The base line is the house style from the rpg skill: ink sketch, hand-drawn, never
|
|
* photorealistic. Note that the sixteen existing duty-roster plates are NOT in this
|
|
* style — they are flat vector illustration on a plain ground — so a bestiary generated
|
|
* here will not match them. That is a deliberate split: the agents are the cast and look
|
|
* like portraits, the bestiary is case material and looks like case material.
|
|
*/
|
|
export const TOKENS = {
|
|
"[BASE]": "hand-drawn ink and pencil illustration, single-weight line work, sparse wash, "
|
|
+ "heavy negative space, not photorealistic",
|
|
"[CREATURE]": "[BASE], full figure on a plain ground, neutral background, "
|
|
+ "field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism",
|
|
"[PERSON]": "[BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 "
|
|
+ "--no photography, photorealism"
|
|
};
|
|
|
|
/**
|
|
* Words Midjourney's moderation refuses even in innocent or negated use, learned in
|
|
* production and recorded in the midjourney-bridge memory. A refusal is EPHEMERAL — it
|
|
* never appears in the channel — so the failure mode is a job that simply never arrives
|
|
* and twenty minutes of wondering whether the account is out of fast hours. Cheaper to
|
|
* catch here than to sit through.
|
|
*/
|
|
/* Learned in production, one silent stall at a time. "bare" was added on 2026-09-23 after
|
|
the martlet failed three times over ninety minutes: the gryphon succeeded in the same
|
|
run, so it was not the service, and replacing that single word made the martlet return
|
|
immediately. A moderation refusal never appears in the channel, so this is the only
|
|
record that the word costs half an hour. */
|
|
const BANNED = ["gore", "bust", "corpse", "mutilat", "blood-soaked", "bare"];
|
|
|
|
/* ---------------------------------------------------------------- derivation */
|
|
|
|
const bandOf = (n, bands) => bands.find(([max]) => n <= max)?.[1] ?? bands.at(-1)[1];
|
|
|
|
const sizeWords = siz => bandOf(Number(siz) || 12, [
|
|
[6, "very small, no higher than a person's knee"],
|
|
[9, "small, about the size of a child"],
|
|
[14, "adult human scale"],
|
|
[18, "large, standing well above a tall man"],
|
|
[99, "huge, roughly twice the height of a man"]
|
|
]);
|
|
|
|
const buildWords = ch => bandOf((Number(ch?.str) || 12) / (Number(ch?.siz) || 12), [
|
|
[0.85, "thin, wasted, not strong"],
|
|
[1.15, "ordinarily built"],
|
|
[1.60, "heavy and powerfully built"],
|
|
[99, "densely and wrongly strong for its size"]
|
|
]);
|
|
|
|
/* Heavy natural armour does not look the same on a heraldic beast as on a far-side
|
|
thing. The single band list read "plated like machinery" at 6+, which drew the
|
|
guildhall gryphon — six centuries of carved stone and feather — as a mech.
|
|
|
|
That was fixed once, by branching the SIMILE on era, and the gryphon came back as a
|
|
mech again: the branch changed what the plating was compared to while both arms still
|
|
opened with the word "armoured", and that word is what Midjourney draws. Changing the
|
|
reasoning is not the same as changing the cause.
|
|
|
|
So no natural creature's description contains "armour" at all now. A beast's armour is
|
|
its hide, and the clause says hide. The word survives only where it is meant literally
|
|
— a far-side thing that really is plated — and `negativesFor` below asks for worn
|
|
armour to be left out, because a word choice is an argument and a --no is closer to a
|
|
guarantee. */
|
|
const armourWords = (n, era = "antique") => bandOf(Number(n) || 0, [
|
|
/* NOT "bare". Midjourney's moderation refuses that word — it reads as nudity — and a
|
|
refusal is ephemeral, so the job simply never arrives. The martlet stalled for thirty
|
|
minutes three times; changing this one word and nothing else made it return. Thirteen
|
|
creatures carried it, every one of them a creature with no natural armour. */
|
|
[0, "thin-skinned, nothing between it and a blow"],
|
|
[3, "tough hide"],
|
|
[6, "thick natural plating, part of the animal"],
|
|
[99, era === "future" || era === "anomalous"
|
|
? "heavily armoured, plated like machinery"
|
|
: "hide and scale thick as carved stone, weathered and old, its own body its only protection"]
|
|
]);
|
|
|
|
/**
|
|
* What must not be in the picture, beyond the house negatives.
|
|
*
|
|
* A creature that is old and heavy reads to Midjourney as a thing in plate, and a
|
|
* creature described as strong reads as a thing holding a weapon. Both are stated
|
|
* positively elsewhere in the prompt and neither can be talked out of the image.
|
|
*/
|
|
function negativesFor(spec, era, isPerson) {
|
|
if (isPerson) return [];
|
|
const out = [];
|
|
/* SHORT, deliberately. The base style already carries two negatives, and this project
|
|
has a recorded failure where prompts with ten or more --no terms never returned a
|
|
grid at all while the same prompt with four came back in two minutes — a stall that
|
|
is indistinguishable from a moderation refusal and so gets misdiagnosed every time.
|
|
The first version of this function pushed six armour terms and three weapon terms,
|
|
which took the gryphon to eight and into that band. Three terms and one; steer with
|
|
the prompt, which is what the armourWords rewrite above is for. */
|
|
if (era !== "future" && era !== "anomalous") out.push("plate armour", "machinery", "robot");
|
|
// "no weapon — it uses its own limbs" is already in the prompt, positively. An animal
|
|
// is still handed a spear often enough to be worth stating twice.
|
|
if (!(spec.weapons ?? []).length) out.push("weapons");
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The anatomy clause, read out of the creature's own hit-location table. This is the
|
|
* whole point of the tool: a Vesh prompt cannot ask for a head, because the table it is
|
|
* generated from does not have one.
|
|
*/
|
|
function anatomyWords(species, bodyPlan = "") {
|
|
/* R-311. This read `species` alone, and the bestiary does not put its shape there.
|
|
* A creature's hit locations come from `bodyPlan`; `species` carries characteristic
|
|
* dice and talents. 44 of the 100 creatures added in 32eaee1 are species "baseline"
|
|
* with bodyPlan "quadruped" or "winged", so every one of their prompts fell through
|
|
* to the silent branch below and asked for no shape at all. The proof is on disk:
|
|
* art/creatures/gryphon-u1.png is a wingless gryphon, generated from a spec whose
|
|
* bodyPlan is "winged".
|
|
*
|
|
* This is the exact failure the function was written to prevent, relocated one field
|
|
* to the left. Read the plan that selects the table, then the species. */
|
|
const plan = bodyPlan || species;
|
|
const t = LOCATION_TABLES[plan] ?? LOCATION_TABLES[species] ?? LOCATION_TABLES.baseline;
|
|
if (plan === "quadruped") {
|
|
return "FOUR-LEGGED ANIMAL ANATOMY: stands on four legs, with a head on a neck at "
|
|
+ "the front and hindquarters behind — NOT bipedal, NOT humanoid, no arms and no hands";
|
|
}
|
|
if (plan === "winged") {
|
|
const wings = t.locations.filter(l => /^wing/.test(l.id)).length;
|
|
return `FOUR-LEGGED ANIMAL ANATOMY WITH ${wings} LARGE WINGS: stands on four legs, `
|
|
+ "head on a neck at the front, and a great wing on each shoulder, clearly visible "
|
|
+ "and fully drawn — NOT bipedal, NOT humanoid, no arms and no hands";
|
|
}
|
|
if (species === "vesh") {
|
|
return "STRONGLY ASYMMETRIC anatomy: one long grasping limb on one side and one short "
|
|
+ "bracing limb on the other, and NO HEAD AT ALL — a raised sensory ridge runs down "
|
|
+ "the back where a head would be";
|
|
}
|
|
if (species === "cadence") {
|
|
return `NOT ONE BODY: ${t.locations.length} small separate bodies moving together as a `
|
|
+ "single creature, none of them with a head, arranged as a loose group";
|
|
}
|
|
/* Deliberately silent for a baseline body on a baseline species.
|
|
*
|
|
* A case file that eats other case files is authored bodyPlan "baseline" and is not a
|
|
* humanoid; it simply has no better table. On a monster that is not a claim about
|
|
* anatomy — it is the absence of one, because vesh and cadence have never been used by
|
|
* anything. Saying "two arms and two legs, one head" here would put a bipedal werewolf
|
|
* in the prompt for the barghest and a little man in the prompt for the carrion file,
|
|
* and the whole argument for deriving prompts from statblocks is that they do not lie.
|
|
*
|
|
* So the prompt says nothing about shape and lets the description carry it, and
|
|
* `list` marks the creature as anatomically unmodelled. A silent wrongness becomes a
|
|
* visible piece of work. */
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether this is a person, which decides whether the prompt asks for a portrait or a
|
|
* field-guide plate.
|
|
*
|
|
* NOT from era. The first cut tested `eraOf(spec) === "modern"` and duly classified Mr
|
|
* Bewley of the Registry, a ticket inspector and every soul in 1881 Cordera as monsters,
|
|
* because their kit is old. A person in 1881 is still a person; era answers "when", not
|
|
* "what". The structural questions are whether anybody classified it as a monster, and
|
|
* whether it carries things that were manufactured and has skin rather than plating.
|
|
*/
|
|
export function isPersonSpec(spec, kind) {
|
|
if (kind === "agent") return true;
|
|
/* R-311. Structure before label.
|
|
*
|
|
* `if (tag) return true` below fails OPEN: any role tag the list has not heard of is
|
|
* read as a person. MONSTER_TAGS named three tags; the bestiary added in 32eaee1 uses
|
|
* twelve, so ANOMALOUS, HERALDRY, BEAST, LIVESTOCK, REVENANT, FAR-SIDE, MENAGERIE,
|
|
* HYBRID and ECHO — 43 creatures — all fell through and were prompted as "portrait of
|
|
* X, waist-up portrait". art/creatures/gryphon-u1.png is the receipt: a wingless
|
|
* gryphon drawn as a person, from a spec whose bodyPlan is "winged".
|
|
*
|
|
* The tag list is free text that grows whenever somebody writes a new creature.
|
|
* bodyPlan cannot drift the same way — it selects the hit-location table, so the
|
|
* engine already enforces it. A four-legged or winged body is not a person, whatever
|
|
* its role line happens to say. */
|
|
if ((spec.bodyPlan ?? "baseline") !== "baseline") return false;
|
|
const tag = roleTagOf(spec);
|
|
if (tag && MONSTER_TAGS.includes(tag)) return false;
|
|
if (tag) return true; // CIVILIAN, DEPARTMENT, ENTRY, REAL
|
|
if ((spec.species ?? "baseline") !== "baseline") return false;
|
|
if (carriesNothingManufactured(spec)) return false; // it was issued nothing by anyone
|
|
return (Number(spec.naturalArmour) || 0) < 2; // skin, not plating
|
|
}
|
|
|
|
/**
|
|
* A creature whose shape the rules do not actually describe: not a person, yet carrying
|
|
* the default human anatomy because nothing in the bestiary has ever used a species that
|
|
* is not baseline. See anatomyWords.
|
|
*/
|
|
export const anatomyUnmodelled = (spec, kind) =>
|
|
!isPersonSpec(spec, kind) && (spec.species ?? "baseline") === "baseline";
|
|
|
|
const weaponWords = (spec, isPerson) => {
|
|
const w = WEAPON.get((spec.weapons ?? [])[0] ?? "");
|
|
if (!w) return null;
|
|
// Worth saying of a monster, because it tells the illustrator to draw claws rather than
|
|
// a knife. Not worth saying of a registry clerk, who is not carrying a weapon for the
|
|
// same reason most people are not.
|
|
if (w.fam === "brawl" && !w.mass) return isPerson ? null : "no weapon — it uses its own limbs";
|
|
return `carrying ${/^[aeiou]/i.test(w.name) ? "an" : "a"} ${w.name.toLowerCase()}`;
|
|
};
|
|
|
|
/**
|
|
* The descriptive half of the role line.
|
|
*
|
|
* The first cut of this used `tactics`, on the theory that behaviour is where a creature
|
|
* lives. It is — but it is written for a GM, not for an illustrator: "Advances by bounds
|
|
* — 15 m per round while firing, 30 m if not" put ranges and rates of fire into an art
|
|
* prompt, and an image model will earnestly try to draw the number 15. `role` is the
|
|
* one-line essence the house style requires of every NPC, and it is already exactly the
|
|
* sentence you would give an artist: "The black dog on the road out of the village."
|
|
*
|
|
* The shouted category prefix is stripped — it classifies the creature rather than
|
|
* describing it, and it is already carried by the style token.
|
|
*/
|
|
const MECHANICAL = /\b(?:HP|POWER|MW|armour|damage)\b\s*[:.]?\s*\d|\d+\s*%|\b\d+d\d+|\b\d+\s?m\b|\bd100\b/i;
|
|
|
|
const descriptionWords = spec => {
|
|
let src = String(spec.role ?? "").trim();
|
|
|
|
// Drop the classifying sentence if the line opens with a shouted tag: "BUREAU, present
|
|
// day. Registry. Holds the case file" describes the department before it describes the
|
|
// man, and only the second half is art direction.
|
|
if (/^[A-Z]{3,}\b/.test(src)) src = src.replace(/^[^.]*\.\s*/, "");
|
|
|
|
/* Scenario cast roles sometimes carry the statblock inline — "An invention that will
|
|
not come indoors. HP 20, armour 2." — and an image model will cheerfully try to draw
|
|
the 20. Sentences with mechanics in them are dropped rather than truncated, because
|
|
the descriptive ones are usually first and the numbers usually last. */
|
|
const kept = src.split(/(?<=[.!?])\s+/)
|
|
.map(s => s.trim())
|
|
.filter(s => s && s.length >= 14 && !MECHANICAL.test(s))
|
|
.slice(0, 2);
|
|
|
|
const out = kept.join(" ").replace(/\s+/g, " ").trim();
|
|
if (!out) return null;
|
|
return out.length > 200 ? out.slice(0, 197) + "..." : out;
|
|
};
|
|
|
|
/** Everything above, assembled. Returns { prompt, token, warnings }. */
|
|
export function promptFor(spec, kind = "npc") {
|
|
const species = spec.species ?? "baseline";
|
|
const era = eraOf(spec);
|
|
const isPerson = isPersonSpec(spec, kind);
|
|
const token = isPerson ? "[PERSON]" : "[CREATURE]";
|
|
|
|
const clauses = [
|
|
isPerson ? `portrait of ${spec.name}` : spec.name,
|
|
descriptionWords(spec),
|
|
!isPerson ? anatomyWords(species, spec.bodyPlan ?? "") : null,
|
|
!isPerson ? sizeWords(spec.ch?.siz) : null,
|
|
!isPerson ? buildWords(spec.ch) : null,
|
|
!isPerson ? armourWords(spec.naturalArmour, era) : null,
|
|
weaponWords(spec, isPerson),
|
|
era === "antique" ? "old, from long before the present day" : null,
|
|
era === "future" ? "recovered technology, not of this time" : null,
|
|
era === "anomalous" ? "subtly wrong, something that should not be in the record" : null,
|
|
token
|
|
].filter(Boolean);
|
|
|
|
/* The style token ends with `--no photography, photorealism`, and --no takes a comma
|
|
list, so anything appended after expansion continues that list rather than starting
|
|
a new clause. This is the only reason the extra negatives can be added here. */
|
|
const base = expand(clauses.join(", ").replace(/\s+/g, " ").trim());
|
|
const extra = negativesFor(spec, era, isPerson);
|
|
const prompt = extra.length ? `${base}, ${extra.join(", ")}` : base;
|
|
|
|
/* A prompt with too many --no terms does not fail, which is the problem: it never
|
|
returns a grid at all, and a job that never arrives looks exactly like a moderation
|
|
refusal — those are ephemeral and never appear in the channel either. The recorded
|
|
case cost twenty minutes of wondering whether the account was out of fast hours.
|
|
Refuse to build one, and say which terms, rather than letting it be diagnosed again
|
|
from scratch. Six is under the observed cliff with room to spare. */
|
|
const NEGATIVE_CAP = 6;
|
|
const negatives = (prompt.split("--no ")[1] ?? "").split(",").map(x => x.trim()).filter(Boolean);
|
|
if (negatives.length > NEGATIVE_CAP) {
|
|
throw new Error(`${spec.key}: ${negatives.length} --no terms (cap ${NEGATIVE_CAP}) — `
|
|
+ `a long negative list stalls the job silently rather than failing it. `
|
|
+ `Steer in the prompt instead. Terms: ${negatives.join(", ")}`);
|
|
}
|
|
|
|
/* REFUSE, rather than warn. These were returned as `warnings` for the caller to print,
|
|
and a warning in a log nobody reads is what let the martlet stall three times: the
|
|
failure it predicts is a job that never arrives, which is indistinguishable from the
|
|
service being slow. The negative cap above refuses for the same reason. */
|
|
const warnings = BANNED.filter(b => prompt.toLowerCase().includes(b));
|
|
if (warnings.length) {
|
|
throw new Error(`${spec.key}: prompt contains ${warnings.map(w => `"${w}"`).join(", ")}, `
|
|
+ `which Midjourney's moderation refuses even in innocent use. The refusal is ephemeral `
|
|
+ `— the job never arrives and never fails — so this must not be sent. Reword the `
|
|
+ `clause that produced it.`);
|
|
}
|
|
return { prompt, token, warnings };
|
|
}
|
|
|
|
function expand(s, depth = 0) {
|
|
if (depth > 8) throw new Error(`style token loop: ${s.slice(0, 60)}`);
|
|
const out = s.replace(/\[([A-Z]+)\]/g, (whole, n) => TOKENS[`[${n}]`] ?? whole);
|
|
return out === s ? out : expand(out, depth + 1);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- art on disk */
|
|
|
|
/** The illustrated portrait for a stem, if there is one. A generated SVG is not one. */
|
|
function illustrationFor(stem) {
|
|
for (const ext of RASTER) {
|
|
if (existsSync(path.join(ART, `${stem}.${ext}`))) return `${stem}.${ext}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Whether an SVG beside it was written by make-portraits, and so is safe to retire. */
|
|
async function isGeneratedSvg(stem) {
|
|
const p = path.join(ART, `${stem}.svg`);
|
|
if (!existsSync(p)) return false;
|
|
return (await readFile(p, "utf8")).includes("tools/make-portraits.mjs");
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- clipboard */
|
|
|
|
/** Copy to the clipboard on whichever of the three machines this is. Never fatal. */
|
|
function toClipboard(text) {
|
|
const cmd = process.platform === "win32" ? ["clip", []]
|
|
: process.platform === "darwin" ? ["pbcopy", []]
|
|
: ["wl-copy", []];
|
|
let r = spawnSync(cmd[0], cmd[1], { input: text });
|
|
if (r.error && process.platform === "linux") {
|
|
r = spawnSync("xclip", ["-selection", "clipboard"], { input: text });
|
|
}
|
|
return !r.error && r.status === 0;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- inbox */
|
|
|
|
const defaultInbox = () => path.join(os.homedir(), "Downloads");
|
|
|
|
/** The newest image in the inbox, optionally only ones newer than `since`. */
|
|
async function newestImage(inbox, since = 0) {
|
|
if (!existsSync(inbox)) throw new Error(`inbox does not exist: ${inbox}`);
|
|
let best = null;
|
|
for (const name of await readdir(inbox)) {
|
|
const ext = path.extname(name).slice(1).toLowerCase();
|
|
if (!RASTER.includes(ext)) continue;
|
|
const full = path.join(inbox, name);
|
|
const st = await stat(full).catch(() => null);
|
|
if (!st?.isFile() || st.mtimeMs <= since) continue;
|
|
if (!best || st.mtimeMs > best.mtimeMs) best = { full, name, ext, mtimeMs: st.mtimeMs };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
const readState = async () =>
|
|
existsSync(STATE) ? JSON.parse(await readFile(STATE, "utf8")) : null;
|
|
|
|
/* ---------------------------------------------------------------- commands */
|
|
|
|
const argv = process.argv.slice(2);
|
|
const cmd = argv[0] ?? "list";
|
|
const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
|
|
const INBOX = path.resolve(opt("--inbox", defaultInbox()));
|
|
|
|
const all = await collectSpecs();
|
|
const byKey = new Map(all.map(r => [r.spec.key, r]));
|
|
const stemOf = spec => spec.portrait ?? spec.key;
|
|
|
|
/** Creatures with no illustration yet, in a stable order. */
|
|
const pending = () => all.filter(({ spec }) => !illustrationFor(stemOf(spec)));
|
|
|
|
function show(key) {
|
|
const row = byKey.get(key);
|
|
if (!row) { console.error(`no creature "${key}" — try: node tools/mj-queue.mjs list`); process.exit(1); }
|
|
const { prompt, warnings } = promptFor(row.spec, row.kind);
|
|
return { row, prompt, warnings };
|
|
}
|
|
|
|
switch (cmd) {
|
|
|
|
case "list": {
|
|
let has = 0, unmodelled = 0;
|
|
for (const { spec, kind } of all) {
|
|
const art = illustrationFor(stemOf(spec));
|
|
if (art) has++;
|
|
const bare = anatomyUnmodelled(spec, kind);
|
|
if (bare) unmodelled++;
|
|
console.log(`${art ? "img " : " "} ${spec.key.padEnd(24)} ${(art ?? "—").padEnd(26)}`
|
|
+ `${eraOf(spec).padEnd(10)}${kind.padEnd(6)}${bare ? "anatomy unmodelled" : ""}`);
|
|
}
|
|
console.log(`\n${has} illustrated, ${all.length - has} not, of ${all.length}.`);
|
|
console.log(`Every one of them already has a generated SVG; this queue is the upgrade path.`);
|
|
if (unmodelled) {
|
|
console.log(`\n${unmodelled} creatures are species "baseline" and are not human-shaped —`);
|
|
console.log(`a black dog, a case file, a machine. Their prompts cannot describe a body and`);
|
|
console.log(`their hit locations are a person's. That is a statblock to write, not a bug here.`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case "status": {
|
|
const st = await readState();
|
|
if (!st) { console.log("nothing taken. `next` takes the next creature."); break; }
|
|
console.log(`taken: ${st.key} at ${new Date(st.at).toLocaleString()}`);
|
|
console.log(`inbox: ${st.inbox}`);
|
|
const img = await newestImage(st.inbox, st.at);
|
|
console.log(img ? `ready to file: ${img.name}` : "no new image in the inbox yet.");
|
|
break;
|
|
}
|
|
|
|
case "prompt": {
|
|
const { prompt, warnings } = show(argv[1]);
|
|
for (const w of warnings) console.error(`WARNING: "${w}" is refused by Midjourney moderation even in innocent use — reword it.`);
|
|
console.log(prompt);
|
|
break;
|
|
}
|
|
|
|
case "next":
|
|
case "take": {
|
|
const key = cmd === "take" ? argv[1] : pending()[0]?.spec.key;
|
|
if (!key) { console.log("nothing left — every creature has an illustration."); break; }
|
|
const { row, prompt, warnings } = show(key);
|
|
|
|
for (const w of warnings) {
|
|
console.error(`\nWARNING: the prompt contains "${w}", which Midjourney refuses even in`);
|
|
console.error(`innocent use. The refusal is ephemeral: no grid ever arrives and nothing`);
|
|
console.error(`explains why. Reword the creature's tactics or role text first.\n`);
|
|
}
|
|
|
|
const at = Date.now();
|
|
await writeFile(STATE, JSON.stringify({ key, at, inbox: INBOX }, null, 2));
|
|
|
|
console.log(`\n── ${row.spec.name} (${key})\n`);
|
|
console.log(prompt);
|
|
console.log(`\n${toClipboard(prompt) ? "Copied to the clipboard." : "Could not reach the clipboard — copy it by hand."}`);
|
|
console.log(`Paste it into Discord as /imagine, upscale the one you want, and save it.`);
|
|
console.log(`Then: node tools/mj-queue.mjs file (watching ${INBOX})`);
|
|
break;
|
|
}
|
|
|
|
case "file": {
|
|
const st = await readState();
|
|
if (!st) { console.error("nothing taken — run `next` first."); process.exit(1); }
|
|
const img = await newestImage(st.inbox, st.at);
|
|
if (!img) {
|
|
console.error(`no image in ${st.inbox} newer than when ${st.key} was taken.`);
|
|
console.error(`Save the upscale first, or pass --inbox <dir> if it lands elsewhere.`);
|
|
process.exit(1);
|
|
}
|
|
await mkdir(ART, { recursive: true });
|
|
const dest = path.join(ART, `${stemOf(byKey.get(st.key).spec)}.${img.ext === "jpeg" ? "jpg" : img.ext}`);
|
|
await copyFile(img.full, dest);
|
|
console.log(`filed ${img.name}\n -> ${path.relative(ROOT, dest)}`);
|
|
|
|
// Retire the placeholder, but only if this tool's sibling wrote it. A hand-made SVG,
|
|
// if one ever exists, is somebody's work and is not ours to delete.
|
|
const stem = stemOf(byKey.get(st.key).spec);
|
|
if (await isGeneratedSvg(stem)) {
|
|
await unlink(path.join(ART, `${stem}.svg`));
|
|
console.log(` retired the generated ${stem}.svg`);
|
|
}
|
|
await unlink(STATE);
|
|
const left = pending().length;
|
|
console.log(`${left} still unillustrated. Next: node tools/mj-queue.mjs next`);
|
|
break;
|
|
}
|
|
|
|
case "watch": {
|
|
const st = await readState();
|
|
if (!st) { console.error("nothing taken — run `next` first, then leave this running."); process.exit(1); }
|
|
console.log(`watching ${st.inbox} for ${st.key} — Ctrl-C to stop.`);
|
|
const started = st.at;
|
|
const tick = async () => {
|
|
const img = await newestImage(st.inbox, started);
|
|
if (!img) return;
|
|
const stem = stemOf(byKey.get(st.key).spec);
|
|
const dest = path.join(ART, `${stem}.${img.ext === "jpeg" ? "jpg" : img.ext}`);
|
|
await copyFile(img.full, dest);
|
|
if (await isGeneratedSvg(stem)) await unlink(path.join(ART, `${stem}.svg`));
|
|
await unlink(STATE).catch(() => {});
|
|
console.log(`filed ${img.name} -> ${path.relative(ROOT, dest)}`);
|
|
console.log(`${pending().length} still unillustrated. Run \`next\` for the next one.`);
|
|
process.exit(0);
|
|
};
|
|
setInterval(tick, 2000);
|
|
break;
|
|
}
|
|
|
|
case "sheet": {
|
|
const out = path.join(ROOT, "docs", "scenarios", "BESTIARY_ART.md");
|
|
const rows = all.map(({ spec, kind }) => {
|
|
const { token } = promptFor(spec, kind);
|
|
// The sheet stores the UNEXPANDED prompt with its token, exactly as
|
|
// THROUGH_TRAIN_ART.md does, so gen-art.mjs can expand it the same way.
|
|
const raw = promptFor(spec, kind).prompt;
|
|
const collapsed = Object.entries(TOKENS)
|
|
.reduce((s, [tk, v]) => s.replace(expand(v), tk), raw);
|
|
return `| \`${stemOf(spec)}\` | ${spec.name} | ${collapsed} |`;
|
|
});
|
|
const md = `# THE CUSTODIANS — bestiary art prompt sheet\n\n`
|
|
+ `Generated by \`tools/mj-queue.mjs sheet\`. Do not edit by hand: every prompt is\n`
|
|
+ `derived from the creature's own statblock, so editing here is overwritten and,\n`
|
|
+ `worse, lets the art disagree with the hit-location table. Change the creature.\n\n`
|
|
+ `## Style tokens\n\n| Token | Text |\n|---|---|\n`
|
|
+ Object.entries(TOKENS).map(([k, v]) => `| \`${k}\` | ${v} |`).join("\n")
|
|
+ `\n\n## Assets\n\n| ID | Creature | Prompt |\n|---|---|---|\n${rows.join("\n")}\n\n`
|
|
+ `*Private convention play materials — not for sale or distribution.*\n`;
|
|
await writeFile(out, md, "utf8");
|
|
console.log(`wrote ${path.relative(ROOT, out)} — ${rows.length} prompts.`);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
console.error(`unknown command "${cmd}". Try: list, prompt, next, take, file, watch, sheet, status`);
|
|
process.exit(1);
|
|
}
|