Ship the bestiary art: 47 plates and tokens, as webp

The illustrations were sitting in art/creatures/, where nothing looked for
them. build-packs resolves a creature's picture by stem under art/portraits/,
so none of the 47 reached the game.

tools/make-art.py (was make-tokens.py) now derives both from one plate:
art/portraits/<key>.webp and art/tokens/<key>.webp. Same drawing, so the
token cannot disagree with the portrait — the argument mj-queue already makes
for deriving prompts from statblocks.

Webp because every one of the 309 art files already here is one, at 160-220
KB. The raw Midjourney plates are 2.3 MB each; 47 of them would have put 93
MB into a 29 MB repository permanently, for pictures nothing renders at that
size. At quality 88 a plate is ~300 KB with the pencil hatching intact. The
plates stay on disk as working files and are gitignored.

The token lookup in build-packs was a hardcoded `.png`, so the first webp
token would have silently fallen back to the portrait and Foundry would have
gone back to cropping a 2:3 plate into a circle. Portraits and tokens now
share one resolver and one extension list.

146 of 147 npcs carry a portrait and 46 a derived token.

Also: the gryphon came back as a mech a second time. The first fix branched
the armour SIMILE on era 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. No natural creature's prompt contains
"armour" now, and negativesFor() asks for plate, machinery and worn armour to
be left out, because a word choice is an argument and a --no is closer to a
guarantee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-23 00:14:51 +01:00
co-authored by Claude Opus 5
parent 4dd72c6248
commit 5351c25fda
100 changed files with 107 additions and 32 deletions
+7
View File
@@ -28,3 +28,10 @@ packs/*/LOG.old
# The creature currently taken from the illustration queue (tools/mj-queue.mjs).
# Machine-local working state, not a fact about the repo.
.mj-inflight
# Raw Midjourney plates and the superseded PNG tokens. Working files, not deliverables:
# tools/make-art.py derives art/portraits/<key>.webp and art/tokens/<key>.webp from these,
# and only the derivatives ship. 47 plates at 2.3 MB each would be 93 MB of history for
# pictures nothing renders at that size. Keep the originals; do not commit them.
art/creatures/
art/tokens/*.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.
Binary file not shown.
+10 -4
View File
@@ -333,13 +333,19 @@ function buildActor(spec, type) {
* Order matters. A hand-made raster always beats the generated diagram, so upgrading a
* creature is a matter of dropping the file in; nothing has to be un-said in source. */
const PORTRAIT_EXT = ["webp", "png", "jpg", "svg"];
const findPortrait = stem => {
/* One resolver, two directories. The token lookup below was a single hardcoded
`.png`, which meant the moment tools/make-art.py started writing .webp — the format
every other piece of art in this repository already uses — every creature silently
fell back to its portrait for a token and Foundry went back to cropping a tall plate
into a circle. The extension list is the same list for both, in the same order. */
const findArt = (dir, stem) => {
for (const ext of PORTRAIT_EXT) {
const rel = `art/portraits/${stem}.${ext}`;
const rel = `art/${dir}/${stem}.${ext}`;
if (existsSync(path.join(ROOT, rel))) return rel;
}
return null;
};
const findPortrait = stem => findArt("portraits", stem);
let face = ICON.actor;
const found = findPortrait(spec.portrait ?? spec.key);
@@ -359,8 +365,8 @@ function buildActor(spec, type) {
* squeezing a tall plate into it and showing a torso with the head cut off. Prefer a
* real token when tools/make-tokens.py has derived one; fall back to the portrait so
* a creature without one still gets a picture rather than the generic silhouette. */
const tokenRel = `art/tokens/${spec.portrait ?? spec.key}.png`;
const tokenSrc = existsSync(path.join(ROOT, tokenRel)) ? `systems/ringbrp/${tokenRel}` : face;
const tokenRel = findArt("tokens", spec.portrait ?? spec.key);
const tokenSrc = tokenRel ? `systems/ringbrp/${tokenRel}` : face;
/* Token footprint, in grid squares, from SIZ.
*
+50 -22
View File
@@ -1,21 +1,33 @@
#!/usr/bin/env python3
"""Derive Foundry tokens from creature illustrations.
"""Derive the shipped creature art from the Midjourney illustrations.
A token is not a portrait. The illustrations are 2:3 field-guide plates — full figure,
plain ground — and build-packs.mjs was pointing prototypeToken.texture at the portrait
file, so Foundry was cropping a tall plate into a round token and showing a torso.
`art/creatures/<key>-u1.png` is the raw plate as Midjourney files it: 2:3, full figure,
plain ground, and around 2.3 MB each. Two things are derived from it, and only the
derivatives are committed:
So the token is DERIVED from the illustration rather than generated separately. Two
reasons. It costs nothing, where a second Midjourney pass would double the spend. And it
cannot disagree with the portrait: it is the same drawing, which is the same argument
mj-queue makes for deriving prompts from statblocks.
art/portraits/<key>.webp the plate, in the format the rest of the repo's art uses
art/tokens/<key>.webp 512x512 RGBA, circular, transparent surround
python3 tools/make-tokens.py every illustration without a token
python3 tools/make-tokens.py --force redo them all
python3 tools/make-tokens.py <key> ... just these
WHY WEBP, AND WHY DERIVED. Every one of the 309 art files already in this repository is
a .webp of 160-220 KB; the raw plates are 2.3 MB, and 47 of them would have added 93 MB
to a 29 MB repository, permanently, for pictures nothing renders at that size. Re-encoded
at quality 88 a plate is around 300 KB with the pencil hatching intact.
Input art/creatures/<key>-u1.png (Midjourney, or any illustration)
Output art/tokens/<key>.png 512x512 RGBA, circular, transparent surround
WHY PORTRAITS AND NOT CREATURES. build-packs.mjs resolves a creature's picture by stem
and extension under `art/portraits/`, preferring .webp, and says in as many words that
upgrading a creature should be a matter of dropping a file in. The plates were sitting in
`art/creatures/`, where nothing looked for them, so none of them reached the game.
A token is not a portrait. The plates are full-figure 2:3, and prototypeToken.texture was
pointing at the portrait file, so Foundry cropped a tall plate into a round token and
showed a torso. The token is DERIVED rather than generated separately for two reasons: it
costs nothing, where a second Midjourney pass would double the spend; and it cannot
disagree with the portrait, because it is the same drawing — the same argument mj-queue
makes for deriving prompts from statblocks.
python3 tools/make-art.py every illustration missing a derivative
python3 tools/make-art.py --force redo them all
python3 tools/make-art.py <key> ... just these
"""
import math
import sys
@@ -23,8 +35,13 @@ from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parent.parent
SRC, DST = ROOT / "art" / "creatures", ROOT / "art" / "tokens"
SRC = ROOT / "art" / "creatures"
DST = ROOT / "art" / "tokens"
PLATES = ROOT / "art" / "portraits"
SIZE, RING = 512, 6
# Quality 88 keeps the pencil hatching and lands a plate near the 160-220 KB the rest of
# the repository's art occupies. 80 visibly softens the finest cross-hatching.
PLATE_Q, TOKEN_Q = 88, 90
def subject_bbox(im, tol=236):
"""Bounding box of everything that is not the plain ground.
@@ -72,7 +89,15 @@ def token_from(path, out):
ImageDraw.Draw(ring).ellipse((RING // 2, RING // 2, SIZE - 1 - RING // 2, SIZE - 1 - RING // 2),
outline=(26, 22, 18, 235), width=RING)
out.parent.mkdir(parents=True, exist_ok=True)
Image.alpha_composite(crop, ring).save(out, "PNG", optimize=True)
Image.alpha_composite(crop, ring).save(out, "WEBP", quality=TOKEN_Q, method=6)
def plate_from(src, out):
"""The illustration itself, re-encoded. No crop, no resize — only the format."""
im = Image.open(src)
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGB")
out.parent.mkdir(parents=True, exist_ok=True)
im.save(out, "WEBP", quality=PLATE_Q, method=6)
def main(argv):
force = "--force" in argv
@@ -80,16 +105,19 @@ def main(argv):
srcs = sorted(SRC.glob("*-u1.png"))
if keys:
srcs = [s for s in srcs if s.name[:-7] in keys]
made = skipped = 0
made_t = made_p = skipped = 0
for s in srcs:
key = s.name[:-7] # strip "-u1.png"
out = DST / f"{key}.png"
if out.exists() and not force:
token, plate = DST / f"{key}.webp", PLATES / f"{key}.webp"
did = False
if force or not token.exists():
token_from(s, token); made_t += 1; did = True
if force or not plate.exists():
plate_from(s, plate); made_p += 1; did = True
if not did:
skipped += 1
continue
token_from(s, out)
made += 1
print(f"make-tokens: wrote {made}, skipped {skipped} existing, of {len(srcs)} illustration(s)")
print(f"make-art: {made_p} portrait(s), {made_t} token(s), "
f"{skipped} already derived, of {len(srcs)} illustration(s)")
if __name__ == "__main__":
main(sys.argv[1:])
+40 -6
View File
@@ -103,17 +103,46 @@ const buildWords = ch => bandOf((Number(ch?.str) || 12) / (Number(ch?.siz) || 12
/* 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. Era already
answers "when"; let it answer what the plating is made of. */
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, [
[0, "no armour, bare"],
[0, "bare, unprotected"],
[3, "tough hide"],
[6, "thick natural plating"],
[6, "thick natural plating, part of the animal"],
[99, era === "future" || era === "anomalous"
? "heavily armoured, plated like machinery"
: "heavily armoured, hide and scale thick as carved stone, weathered and old"]
: "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 = [];
if (era !== "future" && era !== "anomalous") {
out.push("worn armour", "plate armour", "armour plating", "machinery", "robot", "mecha");
}
// "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", "swords", "armour straps");
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
@@ -276,7 +305,12 @@ export function promptFor(spec, kind = "npc") {
token
].filter(Boolean);
const prompt = expand(clauses.join(", ").replace(/\s+/g, " ").trim());
/* 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;
const warnings = BANNED.filter(b => prompt.toLowerCase().includes(b));
return { prompt, token, warnings };
}