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>
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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:])
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||