442 lines
22 KiB
Python
442 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Animation sheets (4 frames x 5 views) for one creature, in the imp's hack-sheet layout.
|
|
|
|
make_anim.py gen <id> <anim> [front|back|both] [--suffix S] fal requests (nano-banana-pro/edit)
|
|
make_anim.py pix <id> <anim> [--front F] [--back B] pixelize, align, stack, preview
|
|
|
|
<anim> is walk or attack. Files (in art/generated/sprites/anim/):
|
|
<id>-<anim>-front-raw<S>.png request 1: the 5x4 sheet, rows 1-3 (front, front 3/4, side) used
|
|
<id>-<anim>-back-raw<S>.png request 2: 2x4 sheet, back three-quarter and back
|
|
<id>-<anim>-32.png (128x160), <id>-<anim>-64.png (256x320), <id>-<anim>-<size>-preview.png
|
|
|
|
Run with the Pillow venv: /tmp/sprvenv/bin/python -I make_anim.py ...
|
|
The key is read from ~/.config/dungeoncraft/fal-key and never printed.
|
|
|
|
Pixel grid: every view row is pixelized with tools/pixelize_sprite.py (--grid 1x4, the idle
|
|
sheet's palette). The step (source pixels per output pixel) is chosen per row so that the
|
|
figures' "core" height (the body without thin weapon parts sticking out above or below, see
|
|
core()) is the median core height of the matching idle view; each frame is then shifted so that
|
|
its core bottom sits on the feet row and its core centre on the idle view's core centre.
|
|
"""
|
|
import argparse, base64, io, json, os, statistics, subprocess, sys, urllib.request
|
|
|
|
from PIL import Image
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SPR = os.path.normpath(os.path.join(HERE, ".."))
|
|
ANIM = os.path.join(SPR, "anim")
|
|
ROOT = os.path.normpath(os.path.join(SPR, "..", "..", ".."))
|
|
TOOL = os.path.join(ROOT, "tools", "pixelize_sprite.py")
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(ROOT, "tools"))
|
|
from creatures import BY_ID, GREEN # noqa: E402
|
|
import pixelize_sprite as ps # noqa: E402
|
|
|
|
VIEWS = ["front", "front three-quarter", "side", "back three-quarter", "back"]
|
|
SIZES = ((32, 31), (64, 63)) # cell size, feet row
|
|
|
|
# Per creature: the weapon (as it appears in the idle sheet) and the attack frames.
|
|
# Front views: what the frames show from the front; back: the same, seen from behind.
|
|
WEAPON = {
|
|
"knight": "the same short straight steel sword in the right hand",
|
|
"kobold": "the same wooden spear with the steel tip in the right hand",
|
|
}
|
|
ATTACK = {
|
|
"knight": dict(
|
|
what="a sword swing: the knight swings the sword with the right hand from high over the right shoulder diagonally down across the body at an enemy in front of it",
|
|
front=["wind-up - the sword raised high over the right shoulder, the blade pointing up and back, body turned slightly back",
|
|
"strike - the sword swinging down diagonally in front of the helm, body turning forward",
|
|
"impact - the sword fully swung through, the blade low in front of the body at the left hip, body leaning forward",
|
|
"recover - the sword pulled back up to the ready position at chest height, body upright"],
|
|
back=["wind-up - the sword raised high over the right shoulder, the blade showing above the helm",
|
|
"strike - the sword coming down, partly hidden by the helm and shoulders",
|
|
"impact - the sword in front of the knight, mostly hidden by the body, only the blade tip shows low at the left side",
|
|
"recover - the sword pulled back up, its blade visible beside the right shoulder"]),
|
|
"kobold": dict(
|
|
what="a spear thrust: the kobold holds the spear level with both hands and stabs it forward at an enemy in front of it",
|
|
front=["wind-up - the spear pulled back, held level at waist height with both hands, the tip pointing forward, body leaning back",
|
|
"strike - the spear pushed forward, body leaning forward",
|
|
"impact - the spear fully thrust forward, arms stretched out, body lunging forward, a small white spark at the spear tip",
|
|
"recover - the spear pulled back to the ready position, body upright"],
|
|
back=["wind-up - the spear pulled back and held level at waist height, its butt end sticking out behind the kobold",
|
|
"strike - the spear pushed forward away from the viewer, mostly hidden by the body",
|
|
"impact - the spear fully thrust forward, arms stretched out, the spear hidden by the body except a short piece of shaft, a small white spark at the tip beyond the head",
|
|
"recover - the spear pulled back to the ready position, the shaft visible beside the body"]),
|
|
}
|
|
WALK_FRONT = ["contact - the right leg stepping forward with the heel down, the left leg behind, arms swinging opposite to the legs",
|
|
"passing - the legs passing each other under the body, the body slightly higher",
|
|
"contact - the left leg stepping forward with the heel down, the right leg behind, arms swinging the other way",
|
|
"passing - the legs passing each other under the body, the body slightly higher"]
|
|
WALK_BACK = WALK_FRONT
|
|
|
|
|
|
def bg_of(cid):
|
|
return ("green (#00FF00)", (0, 255, 0)) if cid in GREEN else ("magenta (#FF00FF)", (255, 0, 255))
|
|
|
|
|
|
def anim_text(cid, anim, part):
|
|
c = BY_ID[cid]
|
|
weapon = WEAPON.get(cid, "")
|
|
if anim == "walk":
|
|
what = f"a looping walk cycle: the {c['name'].lower()} walks forward in the direction it faces, at a steady pace" + (
|
|
f", carrying {weapon} as in the idle pose" if weapon else "")
|
|
frames = WALK_FRONT if part == "front" else WALK_BACK
|
|
feet = "The figure walks in place (it does not move across the cell); the feet stand on the baseline of the row."
|
|
else:
|
|
a = ATTACK[cid]
|
|
what = a["what"]
|
|
frames = a[part]
|
|
feet = "The feet stay in place in all four frames; only arms, weapon, head and upper body move."
|
|
cols = "; ".join(f"{i + 1} {f}" for i, f in enumerate(frames))
|
|
return what, cols, feet
|
|
|
|
|
|
def prompt_front(cid, anim):
|
|
c = BY_ID[cid]
|
|
bgname, _ = bg_of(cid)
|
|
what, cols, feet = anim_text(cid, anim, "front")
|
|
hold = f"the character holds {WEAPON[cid]}" if cid in WEAPON else "the character keeps all its equipment"
|
|
return f"""The attached image is a pixel art sprite sheet of the "{c['name']}" in five views (left to right: front, front three-quarter turned right, side facing right, back three-quarter turned away to the right, back). {c['desc']}
|
|
|
|
Create a new pixel art sprite sheet of exactly the same character - same proportions, colours, outline, pixel size, clothes and equipment - showing {what}.
|
|
|
|
Layout: a grid of 5 rows and 4 columns, every figure the same size, evenly spaced, each in its own equal cell, all standing on the same baseline within their row.
|
|
Rows (one view per row, top to bottom): 1 front view facing the viewer, 2 front three-quarter view turned to the right, 3 side view facing right, 4 back three-quarter view turned away to the right, 5 back view.
|
|
Columns (animation frames, left to right): {cols}.
|
|
{feet}
|
|
Every frame of a row keeps exactly that row's view direction (the side view row stays in profile facing right in all four frames, the back rows never show the face), and {hold} in every single frame.
|
|
|
|
Style: identical to the attached sheet - crisp retro pixel art, hard square pixels, one-pixel near-black outline, limited palette, no anti-aliasing, no blur. Flat pure {bgname} background everywhere, no ground, no shadows, no text, no labels, no frame lines, no grid lines, no motion lines."""
|
|
|
|
|
|
def prompt_back(cid, anim, retry=False):
|
|
c = BY_ID[cid]
|
|
bgname, _ = bg_of(cid)
|
|
what, cols, feet = anim_text(cid, anim, "back")
|
|
note = ("Every one of the eight figures, including the strike frames, is seen from behind: no visor, no face, no eyes and no chest emblem anywhere. "
|
|
"The back three-quarter row shows the body turned clearly to the right (a little of its right side visible), the back row shows it straight from behind.\n") if retry else ""
|
|
return f"""The first attached image is a pixel art sprite sheet of the "{c['name']}" in five idle views (front, front three-quarter, side, back three-quarter, back). The second attached image shows the same character's animation ({what}) in four frames (columns) for the front, front three-quarter and side views.
|
|
|
|
Create the same four-frame animation for the two views seen FROM BEHIND, where the character faces away from the viewer and we see the back of its head, its back and its clothes from behind; the face and eyes are never visible:
|
|
{note}Row 1: back three-quarter view, the character turned away from the viewer towards the upper right.
|
|
Row 2: back view, the character seen straight from behind.
|
|
Columns (frames, left to right): {cols}.
|
|
{feet}
|
|
|
|
Layout: a grid of exactly 2 rows and 4 columns, every figure the same size and the same character size as in the attached sheets, evenly spaced, each in its own equal cell, all standing on the same baseline.
|
|
Style: identical to the attached sheets - same character, proportions, colours, outline and pixel size, crisp retro pixel art, hard square pixels, no anti-aliasing, no blur. Flat pure {bgname} background everywhere, no ground, no shadows, no text, no labels, no frame lines, no grid lines."""
|
|
|
|
|
|
def idle_crop(cid):
|
|
"""The --crop of the idle sheet (scripts/extra.txt), as fractions, or None."""
|
|
p = os.path.join(HERE, "extra.txt")
|
|
if os.path.exists(p):
|
|
for line in open(p):
|
|
parts = line.split()
|
|
if parts and parts[0] == cid and "--crop" in parts:
|
|
return [float(v) for v in parts[parts.index("--crop") + 1].split(",")]
|
|
return None
|
|
|
|
|
|
def clean_ref(img, key):
|
|
"""The image with every background pixel (and frame line) painted in the flat key colour."""
|
|
img = img.convert("RGB")
|
|
mask = ps.background_mask(img)
|
|
w, h = img.size
|
|
out = img.copy()
|
|
px = out.load()
|
|
for y in range(h):
|
|
row = mask[y]
|
|
for x in range(w):
|
|
if row[x]:
|
|
px[x, y] = key
|
|
return out
|
|
|
|
|
|
def uri(img):
|
|
b = io.BytesIO()
|
|
img.save(b, "PNG")
|
|
return "data:image/png;base64," + base64.b64encode(b.getvalue()).decode()
|
|
|
|
|
|
def fal(prompt, images, aspect):
|
|
key = open(os.path.expanduser("~/.config/dungeoncraft/fal-key")).read().strip()
|
|
body = {"prompt": prompt, "image_urls": [uri(i) for i in images], "aspect_ratio": aspect,
|
|
"resolution": "2K", "num_images": 1, "output_format": "png"}
|
|
req = urllib.request.Request("https://fal.run/fal-ai/nano-banana-pro/edit", data=json.dumps(body).encode(),
|
|
headers={"Authorization": "Key " + key, "Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=900) as r:
|
|
res = json.load(r)
|
|
with urllib.request.urlopen(res["images"][0]["url"], timeout=300) as r:
|
|
return Image.open(io.BytesIO(r.read())).convert("RGB")
|
|
|
|
|
|
def idle_ref(cid):
|
|
raw = Image.open(os.path.join(SPR, f"{cid}-raw.png")).convert("RGB")
|
|
f = idle_crop(cid)
|
|
if f:
|
|
w, h = raw.size
|
|
raw = raw.crop((int(f[0] * w), int(f[1] * h), int(f[2] * w), int(f[3] * h)))
|
|
raw = raw.resize((raw.width // 2, raw.height // 2), Image.LANCZOS)
|
|
return clean_ref(raw, bg_of(cid)[1])
|
|
|
|
|
|
def row_bands(img, n):
|
|
"""The horizontal bands (y0, y1) of figure rows: split at empty rows (bands lower than 6% of
|
|
the image are dropped); if that does not give n bands, n bands split at the emptiest rows
|
|
near the even cuts."""
|
|
mask = [[not v for v in r] for r in ps.background_mask(img)]
|
|
occ = [sum(r) for r in mask]
|
|
h = len(occ)
|
|
bands, y0 = [], None
|
|
for y, v in enumerate(occ + [0]):
|
|
if v > 2 and y0 is None:
|
|
y0 = y
|
|
elif v <= 2 and y0 is not None:
|
|
if y - y0 > 0.06 * h:
|
|
bands.append((y0, y))
|
|
y0 = None
|
|
if len(bands) == n:
|
|
# widen to the midpoints of the gaps
|
|
cuts = [0] + [(bands[i][1] + bands[i + 1][0]) // 2 for i in range(n - 1)] + [h]
|
|
return list(zip(cuts[:-1], cuts[1:]))
|
|
print(f" note: {len(bands)} bands found by gaps, wanted {n}; cutting at the emptiest rows")
|
|
cuts = [0]
|
|
for k in range(1, n):
|
|
c = k * h // n
|
|
lo, hi = c - h // (2 * n), c + h // (2 * n)
|
|
cuts.append(min(range(lo, hi), key=lambda y: (occ[y], abs(y - c))))
|
|
cuts.append(h)
|
|
return list(zip(cuts[:-1], cuts[1:]))
|
|
|
|
|
|
def cmd_gen(a):
|
|
os.makedirs(ANIM, exist_ok=True)
|
|
ref = idle_ref(a.id)
|
|
front = os.path.join(ANIM, f"{a.id}-{a.anim}-front-raw{a.suffix}.png")
|
|
if a.part in ("front", "both"):
|
|
img = fal(prompt_front(a.id, a.anim), [ref], "4:5")
|
|
img.save(front)
|
|
print("wrote", front)
|
|
if a.part in ("back", "both"):
|
|
src = a.front_src or front
|
|
fr = Image.open(src).convert("RGB")
|
|
bands = row_bands(fr, 5)
|
|
top = fr.crop((0, 0, fr.width, bands[2][1]))
|
|
img = fal(prompt_back(a.id, a.anim, a.strict), [ref, top], "16:9")
|
|
p = os.path.join(ANIM, f"{a.id}-{a.anim}-back-raw{a.suffix}.png")
|
|
img.save(p)
|
|
print("wrote", p)
|
|
|
|
|
|
# ---- pixel grid -------------------------------------------------------------------------
|
|
|
|
def opaque(cell):
|
|
w, h = cell.size
|
|
px = cell.load()
|
|
return [[px[x, y][3] > 128 for x in range(w)] for y in range(h)]
|
|
|
|
|
|
def longest_run(row):
|
|
best, start, cur = (0, 0), 0, 0
|
|
for x, v in enumerate(row + [False]):
|
|
if v:
|
|
if cur == 0:
|
|
start = x
|
|
cur += 1
|
|
else:
|
|
if cur > best[0]:
|
|
best = (cur, start)
|
|
cur = 0
|
|
return best
|
|
|
|
|
|
def core(cell, size):
|
|
"""(top, bottom, centre x) of the body: top = the first row whose longest opaque run is at
|
|
least 20% of the cell wide (a spear tip or blade above the head is narrower), bottom = the
|
|
last row with a run of at least 3 px (feet; a thin shaft below them is not), centre = the
|
|
median centre of the longest runs of the rows in between. None if the cell is empty."""
|
|
m = opaque(cell)
|
|
runs = [longest_run(r) for r in m]
|
|
wide = max(3, round(0.2 * size))
|
|
tops = [y for y, (n, _) in enumerate(runs) if n >= wide]
|
|
bots = [y for y, (n, _) in enumerate(runs) if n >= 3]
|
|
if not tops or not bots:
|
|
return None
|
|
top, bot = tops[0], bots[-1]
|
|
cx = statistics.median(s + n / 2 for n, s in runs[top:bot + 1] if n >= wide)
|
|
return top, bot, cx
|
|
|
|
|
|
def body_width(cell, size):
|
|
"""Median longest opaque run over the core rows (head and body width, for checking that the
|
|
animation keeps the idle proportions)."""
|
|
c = core(cell, size)
|
|
runs = [longest_run(r)[0] for r in opaque(cell)[c[0]:c[1] + 1]]
|
|
return statistics.median(runs)
|
|
|
|
|
|
def cells(sheet, size, row):
|
|
return [sheet.crop((c * size, row * size, (c + 1) * size, (row + 1) * size)) for c in range(sheet.width // size)]
|
|
|
|
|
|
def run_tool(raw_crop, n, size, feet, step, palette, add):
|
|
tmp = f"/tmp/make_anim_{os.getpid()}"
|
|
raw_crop.save(tmp + "-in.png")
|
|
cmd = [sys.executable, "-I", TOOL, tmp + "-in.png", tmp, "--grid", f"1x{n}", "--size", str(size), "--feet", str(feet),
|
|
"--outline-weight", "0.6", "--palette", palette]
|
|
if step:
|
|
cmd += ["--step", f"{step:.4f}"]
|
|
if add:
|
|
cmd += ["--add-colors", add]
|
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
if r.returncode:
|
|
raise SystemExit(r.stderr)
|
|
out = Image.open(tmp + f"-{size}.png").convert("RGBA")
|
|
out.load()
|
|
for f in (tmp + "-in.png", tmp + f"-{size}.png", tmp + f"-{size}-preview.png"):
|
|
if os.path.exists(f):
|
|
os.remove(f)
|
|
last = r.stdout.strip().splitlines()[-1]
|
|
used = float(last.split("step ")[1].split(",")[0])
|
|
return out, used
|
|
|
|
|
|
def shift(cell, dx, dy):
|
|
out = Image.new("RGBA", cell.size, (0, 0, 0, 0))
|
|
out.alpha_composite(cell, (max(dx, 0), max(dy, 0)), (max(-dx, 0), max(-dy, 0)))
|
|
lost = sum(1 for p in cell.get_flattened_data() if p[3] > 128) - sum(1 for p in out.get_flattened_data() if p[3] > 128)
|
|
return out, lost
|
|
|
|
|
|
def pix_source(raw_crop, n, target, size, feet, palette, add):
|
|
"""All n figures of one raw row, at the step that makes their median core height = target."""
|
|
step = None
|
|
for _ in range(4): # the core height is linear in 1/step; converge in a few passes
|
|
sheet, used = run_tool(raw_crop, n, size, feet, step, palette, add)
|
|
hs = [c[1] - c[0] for c in (core(x, size) for x in cells(sheet, size, 0)) if c]
|
|
new = used * statistics.median(hs) / target
|
|
if step and abs(new - step) / step < 0.01:
|
|
break
|
|
step = new
|
|
sheet, used = run_tool(raw_crop, n, size, feet, step, palette, add)
|
|
return cells(sheet, size, 0), used
|
|
|
|
|
|
def parse_view(spec, default_file):
|
|
"""'FILE:BAND/NBANDS:COUNT:PICKS[;...]' -> [(file, band, nbands, count, [picks])]; FILE may
|
|
be empty (= default_file), PICKS like 0,2,1,3 (m = mirrored, e.g. m1)."""
|
|
out = []
|
|
for seg in spec.split(";"):
|
|
f, bn, count, picks = seg.split(":")
|
|
band, nb = (int(v) for v in bn.split("/"))
|
|
out.append((f or default_file, band, nb, int(count), picks.split(",")))
|
|
return out
|
|
|
|
|
|
def cmd_pix(a):
|
|
pre = os.path.join(ANIM, f"{a.id}-{a.anim}")
|
|
front = a.front or pre + "-front-raw.png"
|
|
back = a.back or pre + "-back-raw.png"
|
|
specs = [f"{front}:{v}/5:4:0,1,2,3" for v in range(3)] + [f"{back}:{v}/2:4:0,1,2,3" for v in range(2)]
|
|
for v in a.view or []:
|
|
k, spec = v.split("=", 1)
|
|
specs[int(k)] = spec
|
|
views = [parse_view(sp, front) for sp in specs]
|
|
images, bands = {}, {}
|
|
for segs in views:
|
|
for f, band, nb, count, picks in segs:
|
|
p = f if os.path.isabs(f) or os.path.exists(f) else os.path.join(ANIM, f)
|
|
if p not in images:
|
|
images[p] = Image.open(p).convert("RGB")
|
|
if (p, nb) not in bands:
|
|
bands[p, nb] = row_bands(images[p], nb)
|
|
for size, feet in SIZES:
|
|
idle_path = os.path.join(SPR, f"{a.id}-{size}.png")
|
|
idle = Image.open(idle_path).convert("RGBA")
|
|
sheet = Image.new("RGBA", (4 * size, 5 * size), (0, 0, 0, 0))
|
|
log = [f"{a.id}-{a.anim}-{size}"]
|
|
for v, segs in enumerate(views):
|
|
icell = idle.crop((v * size, 0, (v + 1) * size, size))
|
|
ic, iw = core(icell, size), body_width(icell, size)
|
|
frames, notes = [], []
|
|
for f, band, nb, count, picks in segs:
|
|
p = f if os.path.isabs(f) or os.path.exists(f) else os.path.join(ANIM, f)
|
|
y0, y1 = bands[p, nb][band]
|
|
im = images[p]
|
|
figs, used = pix_source(im.crop((0, y0, im.width, y1)), count, ic[1] - ic[0], size, feet, idle_path, a.add_colors)
|
|
notes.append(f"{os.path.basename(p)} row {band}/{nb} step {used:.2f}")
|
|
for t in picks:
|
|
c = figs[int(t.lstrip("m"))]
|
|
frames.append(c.transpose(Image.FLIP_LEFT_RIGHT) if t.startswith("m") else c)
|
|
if len(frames) != 4:
|
|
raise SystemExit(f"view {v}: {len(frames)} frames, want 4")
|
|
fn = []
|
|
wr = statistics.median(body_width(f, size) for f in frames) / iw
|
|
for col, cell in enumerate(frames):
|
|
c = core(cell, size)
|
|
dx, dy = round(ic[2] - c[2]), feet - c[1]
|
|
moved, lost = shift(cell, dx, dy)
|
|
sheet.alpha_composite(moved, (col * size, v * size))
|
|
fn.append(f"h{c[1] - c[0]} dx{dx:+d} dy{dy:+d}" + (f" CLIPPED {lost}px" if lost else ""))
|
|
log.append(f" {VIEWS[v]} (idle core h {ic[1] - ic[0]}, width ratio {wr:.2f}"
|
|
+ (" WIDER/NARROWER THAN IDLE" if abs(wr - 1) > 0.1 else "") + "): " + "; ".join(notes) + " | " + ", ".join(fn))
|
|
out = pre + f"-{size}.png"
|
|
sheet.save(out)
|
|
preview(sheet, idle, size, pre + f"-{size}-preview.png")
|
|
print("\n".join(log))
|
|
print("wrote", out)
|
|
|
|
|
|
def preview(sheet, idle, size, path):
|
|
sc = 8 if size == 32 else 4
|
|
pad = size // 4
|
|
w = sheet.width + pad + size
|
|
im = Image.new("RGBA", (w, sheet.height), (96, 92, 104, 255))
|
|
im.alpha_composite(sheet, (0, 0))
|
|
for v in range(5):
|
|
im.alpha_composite(idle.crop((v * size, 0, (v + 1) * size, size)), (sheet.width + pad, v * size))
|
|
# feet row guide
|
|
big = im.resize((im.width * sc, im.height * sc), Image.NEAREST)
|
|
px = big.load()
|
|
for v in range(5):
|
|
y = (v * size + size) * sc - 1
|
|
for x in range(0, big.width, 3):
|
|
if px[x, y][3] and px[x, y][:3] == (96, 92, 104):
|
|
px[x, y] = (150, 146, 160, 255)
|
|
big.save(path)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
g = sub.add_parser("gen")
|
|
g.add_argument("id")
|
|
g.add_argument("anim", choices=["walk", "attack"])
|
|
g.add_argument("part", nargs="?", default="both", choices=["front", "back", "both"])
|
|
g.add_argument("--suffix", default="")
|
|
g.add_argument("--front-src", default=None, help="front raw to show in the back request")
|
|
g.add_argument("--strict", action="store_true", help="back request: stress that every frame is seen from behind (retry)")
|
|
g.add_argument("--print", action="store_true", help="only print the prompts")
|
|
p = sub.add_parser("pix")
|
|
p.add_argument("id")
|
|
p.add_argument("anim", choices=["walk", "attack"])
|
|
p.add_argument("--front", default=None)
|
|
p.add_argument("--back", default=None)
|
|
p.add_argument("--view", action="append", help="V=FILE:BAND/NBANDS:COUNT:PICKS[;...] frames of view V "
|
|
"(0 front .. 4 back) from other rows/files; default views 0-2 = rows 0-2 of 5 of the front raw, "
|
|
"3-4 = rows 0-1 of 2 of the back raw, 4 figures each")
|
|
p.add_argument("--add-colors", default="", help="extra palette colours R,G,B;R,G,B")
|
|
a = ap.parse_args()
|
|
if a.cmd == "gen":
|
|
if a.print:
|
|
print(prompt_front(a.id, a.anim), "\n\n----\n", prompt_back(a.id, a.anim))
|
|
else:
|
|
cmd_gen(a)
|
|
else:
|
|
cmd_pix(a)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|